was configured with 'any'.
=== New features in 1.31 ===
+* (T76554) User sub-pages named ….json are now protected in the same way that ….js
+ and ….css pages are, so that configuration options can safely be placed there.
* Wikimedia\Rdbms\IDatabase->select() and similar methods now support
joins with parentheses for grouping.
* As a first pass in standardizing dialog boxes across the MediaWiki product,
"nmred/kafka-php": "0.1.5",
"phpunit/phpunit": "4.8.36",
"psy/psysh": "0.8.11",
- "wikimedia/avro": "1.7.7",
+ "wikimedia/avro": "1.8.0",
"wikimedia/testing-access-wrapper": "~1.0",
"wmde/hamcrest-html-matchers": "^0.1.0"
},
* Maximum length of a comment in UTF-8 characters. Longer comments will be truncated.
* @note This must be at least 255 and not greater than floor( MAX_COMMENT_LENGTH / 4 ).
*/
- const COMMENT_CHARACTER_LIMIT = 1000;
+ const COMMENT_CHARACTER_LIMIT = 500;
/**
* Maximum length of a comment in bytes. Longer comments will be truncated.
$wgGroupPermissions['user']['reupload-shared'] = true;
$wgGroupPermissions['user']['minoredit'] = true;
$wgGroupPermissions['user']['editmyusercss'] = true;
+$wgGroupPermissions['user']['editmyuserjson'] = true;
$wgGroupPermissions['user']['editmyuserjs'] = true;
$wgGroupPermissions['user']['purge'] = true;
$wgGroupPermissions['user']['sendemail'] = true;
$wgGroupPermissions['sysop']['undelete'] = true;
$wgGroupPermissions['sysop']['editinterface'] = true;
$wgGroupPermissions['sysop']['editusercss'] = true;
+$wgGroupPermissions['sysop']['edituserjson'] = true;
$wgGroupPermissions['sysop']['edituserjs'] = true;
$wgGroupPermissions['sysop']['import'] = true;
$wgGroupPermissions['sysop']['importupload'] = true;
// FIXME: Rename editmycssjs to editmyconfig
$wgGrantPermissions['editmycssjs'] = $wgGrantPermissions['editpage'];
$wgGrantPermissions['editmycssjs']['editmyusercss'] = true;
+$wgGrantPermissions['editmycssjs']['editmyuserjson'] = true;
$wgGrantPermissions['editmycssjs']['editmyuserjs'] = true;
$wgGrantPermissions['editmyoptions']['editmyoptions'] = true;
$wgGrantPermissions['editinterface'] = $wgGrantPermissions['editpage'];
$wgGrantPermissions['editinterface']['editinterface'] = true;
$wgGrantPermissions['editinterface']['editusercss'] = true;
+$wgGrantPermissions['editinterface']['edituserjson'] = true;
$wgGrantPermissions['editinterface']['edituserjs'] = true;
$wgGrantPermissions['createeditmovepage'] = $wgGrantPermissions['editpage'];
if ( $namespace == NS_MEDIAWIKI ) {
# Show a warning if editing an interface message
$out->wrapWikiMsg( "<div class='mw-editinginterface'>\n$1\n</div>", 'editinginterface' );
- # If this is a default message (but not css or js),
+ # If this is a default message (but not css, json, or js),
# show a hint that it is translatable on translatewiki.net
- if ( !$this->mTitle->hasContentModel( CONTENT_MODEL_CSS )
+ if (
+ !$this->mTitle->hasContentModel( CONTENT_MODEL_CSS )
+ && !$this->mTitle->hasContentModel( CONTENT_MODEL_JSON )
&& !$this->mTitle->hasContentModel( CONTENT_MODEL_JAVASCRIPT )
) {
$defaultMessageText = $this->mTitle->getDefaultMessageText();
}
if ( $this->getTitle()->isSubpageOf( $user->getUserPage() ) ) {
$isUserCssConfig = $this->mTitle->isUserCssConfigPage();
+ $isUserJsonConfig = $this->mTitle->isUserJsonConfigPage();
+ $isUserJsConfig = $this->mTitle->isUserJsConfigPage();
$warning = $isUserCssConfig
? 'usercssispublic'
- : 'userjsispublic';
+ : ( $isUserJsonConfig ? 'userjsonispublic' : 'userjsispublic' );
$out->wrapWikiMsg( '<div class="mw-userconfigpublic">$1</div>', $warning );
"<div id='mw-usercssyoucanpreview'>\n$1\n</div>",
[ 'usercssyoucanpreview' ]
);
- }
-
- if ( $this->mTitle->isJsSubpage() && $config->get( 'AllowUserJs' ) ) {
+ } elseif ( $isUserJsonConfig /* No comparable 'AllowUserJson' */ ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-userjsonyoucanpreview'>\n$1\n</div>",
+ [ 'userjsonyoucanpreview' ]
+ );
+ } elseif ( $isUserJsConfig && $config->get( 'AllowUserJs' ) ) {
$out->wrapWikiMsg(
"<div id='mw-userjsyoucanpreview'>\n$1\n</div>",
[ 'userjsyoucanpreview' ]
if ( $level === 'user' && !$config->get( 'AllowUserCss' ) ) {
$format = false;
}
+ } elseif ( $content->getModel() == CONTENT_MODEL_JSON ) {
+ $format = 'json';
+ if ( $level === 'user' /* No comparable 'AllowUserJson' */ ) {
+ $format = false;
+ }
} elseif ( $content->getModel() == CONTENT_MODEL_JAVASCRIPT ) {
$format = 'js';
if ( $level === 'user' && !$config->get( 'AllowUserJs' ) ) {
}
# Used messages to make sure grep find them:
- # Messages: usercsspreview, userjspreview, sitecsspreview, sitejspreview
+ # Messages: usercsspreview, userjsonpreview, userjspreview,
+ # sitecsspreview, sitejsonpreview, sitejspreview
if ( $level && $format ) {
$note = "<div id='mw-{$level}{$format}preview'>" .
$this->context->msg( "{$level}{$format}preview" )->text() .
$ifWritesSince = null, $wiki = false, $cluster = false, $timeout = null
) {
if ( $timeout === null ) {
- $timeout = wfIsCLI() ? 86400 : 10;
+ $timeout = wfIsCLI() ? 60 : 10;
}
if ( $cluster === '*' ) {
$this->rlUserModuleState = $exemptStates['user'] = $userState;
}
- $rlClient = new ResourceLoaderClientHtml( $context, $this->getTarget() );
+ $rlClient = new ResourceLoaderClientHtml( $context, [
+ 'target' => $this->getTarget(),
+ ] );
$rlClient->setConfig( $this->getJSVars() );
$rlClient->setModules( $this->getModules( /*filter*/ true ) );
$rlClient->setModuleStyles( $moduleStyles );
}
/**
- * Could this page contain custom CSS or JavaScript for the global UI.
- * This is generally true for pages in the MediaWiki namespace having CONTENT_MODEL_CSS
- * or CONTENT_MODEL_JAVASCRIPT.
+ * Could this MediaWiki namespace page contain custom CSS, JSON, or JavaScript for the
+ * global UI. This is generally true for pages in the MediaWiki namespace having
+ * CONTENT_MODEL_CSS, CONTENT_MODEL_JSON, or CONTENT_MODEL_JAVASCRIPT.
*
- * This method does *not* return true for per-user JS/CSS. Use isCssJsSubpage()
+ * This method does *not* return true for per-user JS/JSON/CSS. Use isUserConfigPage()
* for that!
*
- * Note that this method should not return true for pages that contain and
- * show "inactive" CSS or JS.
+ * Note that this method should not return true for pages that contain and show
+ * "inactive" CSS, JSON, or JS.
*
* @return bool
* @since 1.31
NS_MEDIAWIKI == $this->mNamespace
&& (
$this->hasContentModel( CONTENT_MODEL_CSS )
+ || $this->hasContentModel( CONTENT_MODEL_JSON )
|| $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT )
)
);
/**
* @return bool
- * @deprecated Since 1.31; use ::isSiteConfigPage() instead
+ * @deprecated Since 1.31; use ::isSiteConfigPage() instead (which also checks for JSON pages)
*/
public function isCssOrJsPage() {
wfDeprecated( __METHOD__, '1.31' );
}
/**
- * Is this a "config" (.css or .js) sub-page of a user page?
+ * Is this a "config" (.css, .json, or .js) sub-page of a user page?
*
* @return bool
* @since 1.31
&& $this->isSubpage()
&& (
$this->hasContentModel( CONTENT_MODEL_CSS )
+ || $this->hasContentModel( CONTENT_MODEL_JSON )
|| $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT )
)
);
/**
* @return bool
- * @deprecated Since 1.31; use ::isUserConfigPage() instead
+ * @deprecated Since 1.31; use ::isUserConfigPage() instead (which also checks for JSON pages)
*/
public function isCssJsSubpage() {
wfDeprecated( __METHOD__, '1.31' );
}
/**
- * Trim down a .css or .js subpage title to get the corresponding skin name
+ * Trim down a .css, .json, or .js subpage title to get the corresponding skin name
*
- * @return string Containing skin name from .css or .js subpage title
+ * @return string Containing skin name from .css, .json, or .js subpage title
* @since 1.31
*/
public function getSkinFromConfigSubpage() {
$subpage = $subpage[count( $subpage ) - 1];
$lastdot = strrpos( $subpage, '.' );
if ( $lastdot === false ) {
- return $subpage; # Never happens: only called for names ending in '.css' or '.js'
+ return $subpage; # Never happens: only called for names ending in '.css'/'.json'/'.js'
}
return substr( $subpage, 0, $lastdot );
}
/**
* @deprecated Since 1.31; use ::getSkinFromConfigSubpage() instead
- * @return string Containing skin name from .css or .js subpage title
+ * @return string Containing skin name from .css, .json, or .js subpage title
*/
public function getSkinFromCssJsSubpage() {
wfDeprecated( __METHOD__, '1.31' );
}
/**
- * Is this a .js subpage of a user page?
+ * Is this a JSON "config" sub-page of a user page?
+ *
+ * @return bool
+ * @since 1.31
+ */
+ public function isUserJsonConfigPage() {
+ return (
+ NS_USER == $this->mNamespace
+ && $this->isSubpage()
+ && $this->hasContentModel( CONTENT_MODEL_JSON )
+ );
+ }
+
+ /**
+ * Is this a JS "config" sub-page of a user page?
*
* @return bool
* @since 1.31
}
/**
- * Check CSS/JS sub-page permissions
+ * Check CSS/JSON/JS sub-page permissions
*
* @param string $action The action to check
* @param User $user User to check
* @return array List of errors
*/
private function checkUserConfigPermissions( $action, $user, $errors, $rigor, $short ) {
- # Protect css/js subpages of user pages
+ # Protect css/json/js subpages of user pages
# XXX: this might be better using restrictions
if ( $action != 'patrol' ) {
&& !$user->isAllowedAny( 'editmyusercss', 'editusercss' )
) {
$errors[] = [ 'mycustomcssprotected', $action ];
+ } elseif (
+ $this->isUserJsonConfigPage()
+ && !$user->isAllowedAny( 'editmyuserjson', 'edituserjson' )
+ ) {
+ $errors[] = [ 'mycustomjsonprotected', $action ];
} elseif (
$this->isUserJsConfigPage()
&& !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
&& !$user->isAllowed( 'editusercss' )
) {
$errors[] = [ 'customcssprotected', $action ];
+ } elseif (
+ $this->isUserJsonConfigPage()
+ && !$user->isAllowed( 'edituserjson' )
+ ) {
+ $errors[] = [ 'customjsonprotected', $action ];
} elseif (
$this->isUserJsConfigPage()
&& !$user->isAllowed( 'edituserjs' )
// If we are looking at a css/js user subpage, purge the action=raw.
if ( $this->isUserJsConfigPage() ) {
$urls[] = $this->getInternalURL( 'action=raw&ctype=text/javascript' );
+ } elseif ( $this->isUserJsonConfigPage() ) {
+ $urls[] = $this->getInternalURL( 'action=raw&ctype=application/json' );
} elseif ( $this->isUserCssConfigPage() ) {
$urls[] = $this->getInternalURL( 'action=raw&ctype=text/css' );
}
return; // Client cache fresh and headers sent, nothing more to do.
}
- $gen = $request->getVal( 'gen' );
- if ( $gen == 'css' || $gen == 'js' ) {
- $this->gen = true;
- }
-
$contentType = $this->getContentType();
$maxage = $request->getInt( 'maxage', $config->get( 'SquidMaxage' ) );
$smaxage = $request->getIntOrNull( 'smaxage' );
if ( $smaxage === null ) {
- if ( $contentType == 'text/css' || $contentType == 'text/javascript' ) {
- // CSS/JS raw content has its own CDN max age configuration.
- // Note: Title::getCdnUrls() includes action=raw for css/js pages,
- // so if using the canonical url, this will get HTCP purges.
+ if (
+ $contentType == 'text/css' ||
+ $contentType == 'application/json' ||
+ $contentType == 'text/javascript'
+ ) {
+ // CSS/JSON/JS raw content has its own CDN max age configuration.
+ // Note: Title::getCdnUrls() includes action=raw for css/json/js
+ // pages, so if using the canonical url, this will get HTCP purges.
$smaxage = intval( $config->get( 'ForcedRawSMaxage' ) );
} else {
// No CDN cache for anything else
}
if ( $content === null || $content === false ) {
- // section not found (or section not supported, e.g. for JS and CSS)
+ // section not found (or section not supported, e.g. for JS, JSON, and CSS)
$text = false;
} else {
$text = $content->getNativeData();
}
}
- if ( $text !== false && $text !== '' && $request->getVal( 'templates' ) === 'expand' ) {
+ if ( $text !== false && $text !== '' && $request->getRawVal( 'templates' ) === 'expand' ) {
$text = $wgParser->preprocess(
$text,
$title,
* @return string
*/
public function getContentType() {
- $ctype = $this->getRequest()->getVal( 'ctype' );
+ // Use getRawVal instead of getVal because we only
+ // need to match against known strings, there is no
+ // storing of localised content or other user input.
+ $ctype = $this->getRequest()->getRawVal( 'ctype' );
if ( $ctype == '' ) {
- $gen = $this->getRequest()->getVal( 'gen' );
+ // Legacy compatibilty
+ $gen = $this->getRequest()->getRawVal( 'gen' );
if ( $gen == 'js' ) {
$ctype = 'text/javascript';
} elseif ( $gen == 'css' ) {
'text/x-wiki',
'text/javascript',
'text/css',
+ // FIXME: Should we still allow Zope editing? External editing feature was dropped
'application/x-zope-edit',
'application/json'
];
}
try {
- $content = ContentHandler::makeContent( $text, $this->getTitle() );
+ $content = ContentHandler::makeContent( $text, $titleObj );
} catch ( MWContentSerializationException $ex ) {
$this->dieWithException( $ex, [
'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
return;
}
if ( !$status->getErrors() ) {
- $status->fatal( 'hookaborted' );
+ // This appears to be unreachable right now, because all
+ // code paths will set an error. Could change, though.
+ $status->fatal( 'hookaborted' ); //@codeCoverageIgnore
}
$this->dieStatus( $status );
+ // These two cases will normally have been caught earlier, and will
+ // only occur if something blocks the user between the earlier
+ // check and the check in EditPage (presumably a hook). It's not
+ // obvious that this is even possible.
+ // @codeCoverageIgnoreStart
case EditPage::AS_BLOCKED_PAGE_FOR_USER:
$this->dieWithError(
'apierror-blocked',
case EditPage::AS_READ_ONLY_PAGE:
$this->dieReadOnly();
+ // @codeCoverageIgnoreEnd
case EditPage::AS_SUCCESS_NEW_ARTICLE:
$r['new'] = true;
$status->fatal( 'apierror-noimageredirect-anon' );
break;
case EditPage::AS_IMAGE_REDIRECT_LOGGED:
- $status->fatal( 'apierror-noimageredirect-logged' );
+ $status->fatal( 'apierror-noimageredirect' );
break;
case EditPage::AS_CONTENT_TOO_BIG:
case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED:
// Currently shouldn't be needed, but here in case
// hooks use them without setting appropriate
// errors on the status.
+ // @codeCoverageIgnoreStart
case EditPage::AS_SPAM_ERROR:
$status->fatal( 'apierror-spamdetected', $result['spam'] );
break;
wfWarn( __METHOD__ . ": Unknown EditPage code {$status->value} with no message" );
$status->fatal( 'apierror-unknownerror-editpage', $status->value );
break;
+ // @codeCoverageIgnoreEnd
}
}
$this->dieStatus( $status );
- break;
}
$apiResult->addValue( null, $this->getModuleName(), $r );
}
ApiBase::PARAM_TYPE => 'text',
],
'undo' => [
- ApiBase::PARAM_TYPE => 'integer'
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
],
'undoafter' => [
- ApiBase::PARAM_TYPE => 'integer'
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
],
'redirect' => [
ApiBase::PARAM_TYPE => 'boolean',
$opts += [
'domain' => false,
'cluster' => false,
- 'timeout' => 60,
+ 'timeout' => $this->cliMode ? 60 : 10,
'ifWritesSince' => null
];
$target->getId(),
$guser,
null,
- $tags,
- $current->getId()
+ $tags
);
// Set patrolling and bot flag on the edits, which gets rollbacked.
/** @var ResourceLoader */
private $resourceLoader;
- /** @var string|null */
- private $target;
+ /** @var array */
+ private $options;
/** @var array */
private $config = [];
/**
* @param ResourceLoaderContext $context
- * @param string|null $target [optional] Custom 'target' parameter for the startup module
+ * @param array $options [optional] Array of options
+ * - 'target': Custom parameter passed to StartupModule.
*/
- public function __construct( ResourceLoaderContext $context, $target = null ) {
+ public function __construct( ResourceLoaderContext $context, array $options = [] ) {
$this->context = $context;
$this->resourceLoader = $context->getResourceLoader();
- $this->target = $target;
+ $this->options = $options;
}
/**
}
// Async scripts. Once the startup is loaded, inline RLQ scripts will run.
- // Pass-through a custom target from OutputPage (T143066).
- $startupQuery = $this->target ? [ 'target' => $this->target ] : [];
+ // Pass-through a custom 'target' from OutputPage (T143066).
+ $startupQuery = isset( $this->options['target'] )
+ ? [ 'target' => (string)$this->options['target'] ]
+ : [];
$chunks[] = $this->getLoad(
'startup',
ResourceLoaderModule::TYPE_SCRIPTS,
}
if ( isset( $this->data['nav_urls']['permalink'] ) && $this->data['nav_urls']['permalink'] ) {
$toolbox['permalink'] = $this->data['nav_urls']['permalink'];
- if ( $toolbox['permalink']['href'] === '' ) {
- unset( $toolbox['permalink']['href'] );
- $toolbox['ispermalink']['tooltiponly'] = true;
- $toolbox['ispermalink']['id'] = 't-ispermalink';
- $toolbox['ispermalink']['msg'] = 'permalink';
- } else {
- $toolbox['permalink']['id'] = 't-permalink';
- }
+ $toolbox['permalink']['id'] = 't-permalink';
}
if ( isset( $this->data['nav_urls']['info'] ) && $this->data['nav_urls']['info'] ) {
$toolbox['info'] = $this->data['nav_urls']['info'];
];
$on['rd_namespace'] = $target->getNamespace();
// Inner LIMIT is 2X in case of stale backlinks with wrong namespaces
- $subQuery = $dbr->selectSQLText(
+ $subQuery = $dbr->buildSelectSubquery(
[ $table, 'redirect', 'page' ],
[ $fromCol, 'rd_from' ],
$conds[$table],
]
);
return $dbr->select(
- [ 'page', 'temp_backlink_range' => "($subQuery)" ],
+ [ 'page', 'temp_backlink_range' => $subQuery ],
[ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ],
[],
__CLASS__ . '::showIndirectLinks',
'editmyoptions',
'editmyprivateinfo',
'editmyusercss',
+ 'editmyuserjson',
'editmyuserjs',
'editmywatchlist',
'editsemiprotected',
'editusercss',
+ 'edituserjson',
'edituserjs',
'hideuser',
'import',
"cascadeprotected": "This page has been protected from editing because it is transcluded in the following {{PLURAL:$1|page, which is|pages, which are}} protected with the \"cascading\" option turned on:\n$2",
"namespaceprotected": "You do not have permission to edit pages in the <strong>$1</strong> namespace.",
"customcssprotected": "You do not have permission to edit this CSS page because it contains another user's personal settings.",
+ "customjsonprotected": "You do not have permission to edit this JSON page because it contains another user's personal settings.",
"customjsprotected": "You do not have permission to edit this JavaScript page because it contains another user's personal settings.",
"mycustomcssprotected": "You do not have permission to edit this CSS page.",
+ "mycustomjsonprotected": "You do not have permission to edit this JSON page.",
"mycustomjsprotected": "You do not have permission to edit this JavaScript page.",
"myprivateinfoprotected": "You do not have permission to edit your private information.",
"mypreferencesprotected": "You do not have permission to edit your preferences.",
"blocked-notice-logextract": "This user is currently blocked.\nThe latest block log entry is provided below for reference:",
"clearyourcache": "<strong>Note:</strong> After saving, you may have to bypass your browser's cache to see the changes.\n* <strong>Firefox / Safari:</strong> Hold <em>Shift</em> while clicking <em>Reload</em>, or press either <em>Ctrl-F5</em> or <em>Ctrl-R</em> (<em>⌘-R</em> on a Mac)\n* <strong>Google Chrome:</strong> Press <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> on a Mac)\n* <strong>Internet Explorer:</strong> Hold <em>Ctrl</em> while clicking <em>Refresh</em>, or press <em>Ctrl-F5</em>\n* <strong>Opera:</strong> Go to <em>Menu → Settings</em> (<em>Opera → Preferences</em> on a Mac) and then to <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
"usercssyoucanpreview": "<strong>Tip:</strong> Use the \"{{int:showpreview}}\" button to test your new CSS before saving.",
+ "userjsonyoucanpreview": "<strong>Tip:</strong> Use the \"{{int:showpreview}}\" button to test your new JSON before saving.",
"userjsyoucanpreview": "<strong>Tip:</strong> Use the \"{{int:showpreview}}\" button to test your new JavaScript before saving.",
"usercsspreview": "<strong>Remember that you are only previewing your user CSS.\nIt has not yet been saved!</strong>",
+ "userjsonpreview": "<strong>Remember that you are only testing/previewing your user JSON config.\nIt has not yet been saved!</strong>",
"userjspreview": "<strong>Remember that you are only testing/previewing your user JavaScript.\nIt has not yet been saved!</strong>",
"sitecsspreview": "<strong>Remember that you are only previewing this CSS.\nIt has not yet been saved!</strong>",
+ "sitejsonpreview": "<strong>Remember that you are only previewing this JSON config.\nIt has not yet been saved!</strong>",
"sitejspreview": "<strong>Remember that you are only previewing this JavaScript code.\nIt has not yet been saved!</strong>",
- "userinvalidconfigtitle": "<strong>Warning:</strong> There is no skin \"$1\".\nCustom .css and .js pages use a lowercase title, e.g. {{ns:user}}:Foo/vector.css as opposed to {{ns:user}}:Foo/Vector.css.",
+ "userinvalidconfigtitle": "<strong>Warning:</strong> There is no skin \"$1\".\nCustom .css, .json, and .js pages use a lowercase title, e.g. {{ns:user}}:Foo/vector.css as opposed to {{ns:user}}:Foo/Vector.css.",
"updated": "(Updated)",
"note": "<strong>Note:</strong>",
"previewnote": "<strong>Remember that this is only a preview.</strong>\nYour changes have not yet been saved!",
"default": "default",
"prefs-files": "Files",
"prefs-custom-css": "Custom CSS",
+ "prefs-custom-json": "Custom JSON",
"prefs-custom-js": "Custom JavaScript",
- "prefs-common-config": "Shared CSS/JavaScript for all skins:",
+ "prefs-common-config": "Shared CSS/JSON/JavaScript for all skins:",
"prefs-reset-intro": "You can use this page to reset your preferences to the site defaults.\nThis cannot be undone.",
"prefs-emailconfirm-label": "Email confirmation:",
"youremail": "Email:",
"right-editcontentmodel": "Edit the content model of a page",
"right-editinterface": "Edit the user interface",
"right-editusercss": "Edit other users' CSS files",
+ "right-edituserjson": "Edit other users' JSON files",
"right-edituserjs": "Edit other users' JavaScript files",
"right-editmyusercss": "Edit your own user CSS files",
+ "right-editmyuserjson": "Edit your own user JSON files",
"right-editmyuserjs": "Edit your own user JavaScript files",
"right-viewmywatchlist": "View your own watchlist",
"right-editmywatchlist": "Edit your own watchlist. Note some actions will still add pages even without this right.",
"grant-createaccount": "Create accounts",
"grant-createeditmovepage": "Create, edit, and move pages",
"grant-delete": "Delete pages, revisions, and log entries",
- "grant-editinterface": "Edit the MediaWiki namespace and user CSS/JavaScript",
- "grant-editmycssjs": "Edit your user CSS/JavaScript",
+ "grant-editinterface": "Edit the MediaWiki namespace and user CSS/JSON/JavaScript",
+ "grant-editmycssjs": "Edit your user CSS/JSON/JavaScript",
"grant-editmyoptions": "Edit your user preferences",
"grant-editmywatchlist": "Edit your watchlist",
"grant-editpage": "Edit existing pages",
"group-bot.css": "/* CSS placed here will affect bots only */",
"group-sysop.css": "/* CSS placed here will affect sysops only */",
"group-bureaucrat.css": "/* CSS placed here will affect bureaucrats only */",
+ "common.json": "/* Any JSON here will be loaded for all users on every page load. */",
"common.js": "/* Any JavaScript here will be loaded for all users on every page load. */",
"group-autoconfirmed.js": "/* Any JavaScript here will be loaded for autoconfirmed users only */",
"group-user.js": "/* Any JavaScript here will be loaded for registered users only */",
"unlinkaccounts-success": "The account was unlinked.",
"authenticationdatachange-ignored": "The authentication data change was not handled. Maybe no provider was configured?",
"userjsispublic": "Please note: JavaScript subpages should not contain confidential data as they are viewable by other users.",
+ "userjsonispublic": "Please note: JSON subpages should not contain confidential data as they are viewable by other users.",
"usercssispublic": "Please note: CSS subpages should not contain confidential data as they are viewable by other users.",
"restrictionsfield-badip": "Invalid IP address or range: $1",
"restrictionsfield-label": "Allowed IP ranges:",
"cascadeprotected": "Parameters:\n* $1 - number of cascade-protected pages, used for PLURAL\n* $2 - list of cascade-protected pages\n* $3 - (Unused) the action the user attempted to perform",
"namespaceprotected": "Parameters:\n* $1 - namespace name\n* $2 - (Unused) the action the user attempted to perform",
"customcssprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
+ "customjsonprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
"customjsprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
"mycustomcssprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
+ "mycustomjsonprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
"mycustomjsprotected": "Used as error message. Parameters:\n* $1 - (Unused) the action the user attempted to perform",
"myprivateinfoprotected": "Used as error message.",
"mypreferencesprotected": "Used as error message.",
"userpage-userdoesnotexist": "Error message displayed when trying to edit or create a page or a subpage that belongs to a user who is not registered on the wiki.\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}",
"userpage-userdoesnotexist-view": "Shown in user pages of non-existing users. See for example [{{canonicalurl:User:Foo}} User:Foo].\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}",
"blocked-notice-logextract": "{{gender}}\nParameters:\n* $1 - (Optional) the name of the blocked user. Can be used for GENDER.",
- "clearyourcache": "Text at the top of .js/.css pages.\n\nWhen translating browser function names, check how they are translated in the localized versions of these web browsers in your language. If a browser is not translated to it, use English or another language in which browsers are most commonly used by the speakers of your language.",
- "usercssyoucanpreview": "Text displayed on every CSS page.\n\nSee also:\n* {{msg-mw|Userjsyoucanpreview}}\n* {{msg-mw|Showpreview}}",
- "userjsyoucanpreview": "Text displayed on every JavaScript page.\n\nSee also:\n* {{msg-mw|Usercssyoucanpreview}}\n* {{msg-mw|Showpreview}}",
+ "clearyourcache": "Text at the top of .js/.json/.css pages.\n\nWhen translating browser function names, check how they are translated in the localized versions of these web browsers in your language. If a browser is not translated to it, use English or another language in which browsers are most commonly used by the speakers of your language.",
+ "usercssyoucanpreview": "Text displayed on every CSS page.\n\nSee also:\n* {{msg-mw|Userjsyoucanpreview}}\n* {{msg-mw|Userjsonyoucanpreview}}\n* {{msg-mw|Showpreview}}",
+ "userjsonyoucanpreview": "Text displayed on every JSON page.\n\nSee also:\n* {{msg-mw|Usercssyoucanpreview}}\n* {{msg-mw|Userjsyoucanpreview}}\n* {{msg-mw|Showpreview}}",
+ "userjsyoucanpreview": "Text displayed on every JavaScript page.\n\nSee also:\n* {{msg-mw|Userjsonyoucanpreview}}\n* {{msg-mw|Usercssyoucanpreview}}\n* {{msg-mw|Showpreview}}",
"usercsspreview": "Text displayed on preview of every user .css subpage.\n\nSee also:\n* {{msg-mw|Sitecsspreview}}",
+ "userjsonpreview": "Text displayed on preview of every user .json subpage",
"userjspreview": "Text displayed on preview of every user .js subpage",
"sitecsspreview": "Text displayed on preview of .css pages in MediaWiki namespace.\n\nSee also:\n* {{msg-mw|Usercsspreview}}",
+ "sitejsonpreview": "Text displayed on preview of .json pages in MediaWiki namespace",
"sitejspreview": "Text displayed on preview of .js pages in MediaWiki namespace",
"userinvalidconfigtitle": "Parameters:\n* $1 - skin name",
"updated": "{{Identical|Updated}}",
"addsection-preload": "{{notranslate}}",
"addsection-editintro": "{{notranslate}}",
"defaultmessagetext": "Caption above the default message text shown on the left-hand side of a diff displayed after clicking \"Show changes\" when creating a new page in the MediaWiki: namespace",
- "content-failed-to-parse": "Error message indicating that the page's content can not be saved because it is syntactically invalid. This may occurr for content types using serialization or a strict markup syntax.\n\nParameters:\n* $1 – content model, any one of the following messages:\n** {{msg-mw|Content-model-wikitext}}\n** {{msg-mw|Content-model-javascript}}\n** {{msg-mw|Content-model-css}}\n** {{msg-mw|Content-model-text}}\n* $2 – content format as MIME type (e.g. <code>text/css</code>)\n* $3 – specific error message",
+ "content-failed-to-parse": "Error message indicating that the page's content can not be saved because it is syntactically invalid. This may occurr for content types using serialization or a strict markup syntax.\n\nParameters:\n* $1 – content model, any one of the following messages:\n** {{msg-mw|Content-model-wikitext}}\n** {{msg-mw|Content-model-javascript}}\n** {{msg-mw|Content-model-css}}\n** {{msg-mw|Content-model-json}}\n** {{msg-mw|Content-model-text}}\n* $2 – content format as MIME type (e.g. <code>text/css</code>)\n* $3 – specific error message",
"invalid-content-data": "Error message indicating that the page's content can not be saved because it is invalid. This may occurr for content types with internal consistency constraints.",
- "content-not-allowed-here": "Error message indicating that the desired content model is not supported in given localtion.\n* $1 - the human readable name of the content model: {{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}\n* $2 - the title of the page in question",
+ "content-not-allowed-here": "Error message indicating that the desired content model is not supported in given localtion.\n* $1 - the human readable name of the content model: {{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-json}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}\n* $2 - the title of the page in question",
"editwarning-warning": "Uses {{msg-mw|Prefs-editing}}",
"editpage-invalidcontentmodel-title": "Title of error page shown when using an unrecognized content model on EditPage",
"editpage-invalidcontentmodel-text": "Error message shown when using an unrecognized content model on EditPage. $1 is the user's invalid input",
"default": "{{Identical|Default}}",
"prefs-files": "Title of a tab in [[Special:Preferences]].\n{{Identical|File}}",
"prefs-custom-css": "visible on [[Special:Preferences]] -[Skins].\n{{Identical|Custom CSS}}",
+ "prefs-custom-json": "visible on [[Special:Preferences]] -[Skins].\n{{Identical|Custom JSON}}",
"prefs-custom-js": "visible on [[Special:Preferences]] -[Skins].\n{{Identical|Custom JavaScript}}",
"prefs-common-config": "Used as label in [[Special:Preferences#mw-prefsection-rendering|preferences]], tab \"Appearance\", section \"Skin\".\n\nSee also:\n* {{msg-mw|Globalcssjs-custom-css-js}}",
"prefs-reset-intro": "Used in [[Special:Preferences/reset]].",
"right-editcontentmodel": "{{doc-right|editcontentmodel}}",
"right-editinterface": "{{doc-right|editinterface}}",
"right-editusercss": "{{doc-right|editusercss}}\nSee also:\n* {{msg-mw|Right-editmyusercss}}",
+ "right-edituserjson": "{{doc-right|edituserjson}}\nSee also:\n* {{msg-mw|Right-editmyuserjson}}",
"right-edituserjs": "{{doc-right|edituserjs}}\nSee also:\n* {{msg-mw|Right-editmyuserjs}}",
"right-editmyusercss": "{{doc-right|editmyusercss}}\nSee also:\n* {{msg-mw|Right-editusercss}}",
+ "right-editmyuserjson": "{{doc-right|editmyuserjson}}\nSee also:\n* {{msg-mw|Right-edituserjson}}",
"right-editmyuserjs": "{{doc-right|editmyuserjs}}\nSee also:\n* {{msg-mw|Right-edituserjs}}",
"right-viewmywatchlist": "{{doc-right|viewmywatchlist}}",
"right-editmywatchlist": "{{doc-right|editmywatchlist}}",
"group-bot.css": "{{doc-group|bot|css}}",
"group-sysop.css": "{{doc-group|sysop|css}}",
"group-bureaucrat.css": "{{doc-group|bureaucrat|css}}",
+ "common.json": "{{optional}}\nJSON for all users.",
"common.js": "{{optional}}\nJS for all users.",
"group-autoconfirmed.js": "{{doc-group|autoconfirmed|js}}",
"group-user.js": "{{doc-group|user|js}}",
"unlinkaccounts": "Title of the special page [[Special:UnlinkAccounts]] which allows the user to remove linked remote accounts.",
"unlinkaccounts-success": "Account unlinking form success message",
"authenticationdatachange-ignored": "Shown when authentication data change was unsuccessful due to configuration problems.\n\nCf. e.g. {{msg-mw|Passwordreset-ignored}}.",
- "userjsispublic": "A reminder to users that Javascript subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .js. See also {{msg-mw|usercssispublic}}.",
- "usercssispublic": "A reminder to users that CSS subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .css. See also {{msg-mw|userjsispublic}}",
+ "userjsispublic": "A reminder to users that Javascript subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .js. See also {{msg-mw|usercssispublic}} and {{msg-mw|userjsonispublic}}.",
+ "userjsonispublic": "A reminder to users that JSON subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .json. See also {{msg-mw|userjsispublic}} and {{msg-mw|usercssispublic}}",
+ "usercssispublic": "A reminder to users that CSS subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .css. See also {{msg-mw|userjsispublic}} and {{msg-mw|userjsonispublic}}",
"restrictionsfield-badip": "An error message shown when one entered an invalid IP address or range in a restrictions field (such as Special:BotPassword). $1 is the IP address.",
"restrictionsfield-label": "Field label shown for restriction fields (e.g. on Special:BotPassword).",
"restrictionsfield-help": "Placeholder text displayed in restriction fields (e.g. on Special:BotPassword).",
}
$ext = pathinfo( $file, PATHINFO_EXTENSION );
if ( $ext == 'php' || $ext == 'php5' ) {
- # Execute php files
- # We use require and return true here because when you return false
- # the php webserver will discard post data and things like login
- # will not function in the dev environment.
- require $file;
-
- return true;
+ return false;
}
$mime = false;
// Borrow mime type file from MimeAnalyzer
ismodsince
ismulti
isnew
-ispermalink
isroot
isself
isset
"selenium": "killall -0 chromedriver 2>/dev/null || chromedriver --url-base=/wd/hub --port=4444 & grunt webdriver:test; killall chromedriver"
},
"devDependencies": {
+ "bluebird": "3.5.1",
"deepmerge": "1.3.2",
"eslint": "4.9.0",
"eslint-config-wikimedia": "0.5.0",
position: absolute;
top: 50%;
.transform( translateY( -50% ) );
-
- // HACK: Following overrides help icon size and centers it
- &.oo-ui-widget.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
- box-sizing: content-box;
- padding: 0;
-
- .oo-ui-icon-help {
- min-width: initial;
- min-height: initial;
- width: 1.4em;
- height: 1.4em;
- margin-top: 0.2375em;
- }
- }
}
&-header {
$label.append(
$( '<div>' )
.addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
- .append( this.$label )
+ .append( $( '<bdi>' ).append( this.$label ) )
);
if ( this.itemModel.getDescription() ) {
$label.append(
$( '<div>' )
.addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
- .text( this.itemModel.getDescription() )
+ .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
);
}
// Parent
mw.rcfilters.ui.SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
framed: false,
- icon: 'unClip',
+ icon: 'bookmark',
title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
popup: {
classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
}
}, config ) );
// // HACK: Add an icon to the popup head label
- this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'unClip' } ) ).$element );
+ this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
this.input = new OO.ui.TextInputWidget( {
placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
label: $labelNoEntries,
- icon: 'unClip'
+ icon: 'bookmark'
} );
this.menu = new mw.rcfilters.ui.GroupWidget( {
this.button = new OO.ui.PopupButtonWidget( {
classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
label: mw.msg( 'rcfilters-quickfilters' ),
- icon: 'unClip',
+ icon: 'bookmark',
indicator: 'down',
$overlay: this.$overlay,
popup: {
*/
/* global Uint32Array */
( function ( mw, $ ) {
- var userInfoPromise;
+ var userInfoPromise, stickyRandomSessionId;
/**
* Get the current user's groups or rights
// Support: IE 11
crypto = window.crypto || window.msCrypto;
- if ( crypto && crypto.getRandomValues ) {
+ if ( crypto && crypto.getRandomValues && typeof Uint32Array === 'function' ) {
// Fill an array with 2 random values, each of which is 32 bits.
// Note that Uint32Array is array-like but does not implement Array.
rnds = new Uint32Array( 2 );
return hexRnds.join( '' );
},
+ /**
+ * A sticky generateRandomSessionId for the current JS execution context,
+ * cached within this class.
+ *
+ * @return {string} 64 bit integer in hex format, padded
+ */
+ stickyRandomId: function () {
+ if ( !stickyRandomSessionId ) {
+ stickyRandomSessionId = mw.user.generateRandomSessionId();
+ }
+
+ return stickyRandomSessionId;
+ },
+
/**
* Get the current user's database id
*
'DummyContentForTesting' => "$testDir/phpunit/mocks/content/DummyContentForTesting.php",
'DummyNonTextContentHandler' => "$testDir/phpunit/mocks/content/DummyNonTextContentHandler.php",
'DummyNonTextContent' => "$testDir/phpunit/mocks/content/DummyNonTextContent.php",
+ 'DummySerializeErrorContentHandler' =>
+ "$testDir/phpunit/mocks/content/DummySerializeErrorContentHandler.php",
'ContentHandlerTest' => "$testDir/phpunit/includes/content/ContentHandlerTest.php",
'JavaScriptContentTest' => "$testDir/phpunit/includes/content/JavaScriptContentTest.php",
'TextContentTest' => "$testDir/phpunit/includes/content/TextContentTest.php",
'MediaWiki\\Session\\DummySessionBackend'
=> "$testDir/phpunit/mocks/session/DummySessionBackend.php",
'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
+ 'MockMessageLocalizer' => "$testDir/phpunit/mocks/MockMessageLocalizer.php",
# tests/suites
'ParserTestFileSuite' => "$testDir/phpunit/suites/ParserTestFileSuite.php",
}
}
+ private static $schemaOverrideDefaults = [
+ 'scripts' => [],
+ 'create' => [],
+ 'drop' => [],
+ 'alter' => [],
+ ];
+
/**
* Stub. If a test suite needs to test against a specific database schema, it should
* override this method and return the appropriate information from it.
*
- * @return [ $tables, $scripts ] A tuple of two lists, with $tables being a list of tables
- * that will be re-created by the scripts, and $scripts being a list of SQL script
- * files for creating the tables listed.
+ * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
+ * May be used to check the current state of the schema, to determine what
+ * overrides are needed.
+ *
+ * @return array An associative array with the following fields:
+ * - 'scripts': any SQL scripts to run. If empty or not present, schema overrides are skipped.
+ * - 'create': A list of tables created (may or may not exist in the original schema).
+ * - 'drop': A list of tables dropped (expected to be present in the original schema).
+ * - 'alter': A list of tables altered (expected to be present in the original schema).
*/
- protected function getSchemaOverrides() {
- return [ [], [] ];
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ return [];
+ }
+
+ /**
+ * Undoes the dpecified schema overrides..
+ * Called once per test class, just before addDataOnce().
+ *
+ * @param IMaintainableDatabase $db
+ * @param array $oldOverrides
+ */
+ private function undoSchemaOverrides( IMaintainableDatabase $db, $oldOverrides ) {
+ $this->ensureMockDatabaseConnection( $db );
+
+ $oldOverrides = $oldOverrides + self::$schemaOverrideDefaults;
+ $originalTables = $this->listOriginalTables( $db );
+
+ // Drop tables that need to be restored or removed.
+ $tablesToDrop = array_merge( $oldOverrides['create'], $oldOverrides['alter'] );
+
+ // Restore tables that have been dropped or created or altered,
+ // if they exist in the original schema.
+ $tablesToRestore = array_merge( $tablesToDrop, $oldOverrides['drop'] );
+ $tablesToRestore = array_intersect( $originalTables, $tablesToRestore );
+
+ if ( $tablesToDrop ) {
+ $this->dropMockTables( $db, $tablesToDrop );
+ }
+
+ if ( $tablesToRestore ) {
+ $this->recloneMockTables( $db, $tablesToRestore );
+ }
}
/**
- * Applies any schema changes requested by calling setDbSchema().
+ * Applies the schema overrides returned by getSchemaOverrides(),
+ * after undoing any previously applied schema overrides.
* Called once per test class, just before addDataOnce().
*/
private function setUpSchema( IMaintainableDatabase $db ) {
- list( $tablesToAlter, $scriptsToRun ) = $this->getSchemaOverrides();
+ // Undo any active overrides.
+ $oldOverrides = isset( $db->_schemaOverrides ) ? $db->_schemaOverrides
+ : self::$schemaOverrideDefaults;
+
+ if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
+ $this->undoSchemaOverrides( $db, $oldOverrides );
+ }
+
+ // Determine new overrides.
+ $overrides = $this->getSchemaOverrides( $db ) + self::$schemaOverrideDefaults;
+
+ $extraKeys = array_diff(
+ array_keys( $overrides ),
+ array_keys( self::$schemaOverrideDefaults )
+ );
- if ( $tablesToAlter && !$scriptsToRun ) {
+ if ( $extraKeys ) {
throw new InvalidArgumentException(
- 'No scripts supplied for applying the database schema.'
+ 'Schema override contains extra keys: ' . var_export( $extraKeys, true )
);
}
- if ( !$tablesToAlter && $scriptsToRun ) {
+ if ( !$overrides['scripts'] ) {
+ // no scripts to run
+ return;
+ }
+
+ if ( !$overrides['create'] && !$overrides['drop'] && !$overrides['alter'] ) {
throw new InvalidArgumentException(
- 'No tables declared to be altered by schema scripts.'
+ 'Schema override scripts given, but no tables are declared to be '
+ . 'created, dropped or altered.'
);
}
$this->ensureMockDatabaseConnection( $db );
- $previouslyAlteredTables = isset( $db->_alteredMockTables ) ? $db->_alteredMockTables : [];
-
- if ( !$tablesToAlter && !$previouslyAlteredTables ) {
- return; // nothing to do
- }
-
- $tablesToDrop = array_merge( $previouslyAlteredTables, $tablesToAlter );
- $tablesToRestore = array_diff( $previouslyAlteredTables, $tablesToAlter );
+ // Drop the tables that will be created by the schema scripts.
+ $originalTables = $this->listOriginalTables( $db );
+ $tablesToDrop = array_intersect( $originalTables, $overrides['create'] );
if ( $tablesToDrop ) {
$this->dropMockTables( $db, $tablesToDrop );
}
- if ( $tablesToRestore ) {
- $this->recloneMockTables( $db, $tablesToRestore );
- }
-
- foreach ( $scriptsToRun as $script ) {
+ // Run schema override scripts.
+ foreach ( $overrides['scripts'] as $script ) {
$db->sourceFile(
$script,
null,
);
}
- $db->_alteredMockTables = $tablesToAlter;
+ $db->_schemaOverrides = $overrides;
}
private function mungeSchemaUpdateQuery( $cmd ) {
}
}
+ /**
+ * Lists all tables in the live database schema.
+ *
+ * @param IMaintainableDatabase $db
+ * @return array
+ */
+ private function listOriginalTables( IMaintainableDatabase $db ) {
+ if ( !isset( $db->_originalTablePrefix ) ) {
+ throw new LogicException( 'No original table prefix know, cannot list tables!' );
+ }
+
+ $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
+ return $originalTables;
+ }
+
/**
* Re-clones the given mock tables to restore them based on the live database schema.
+ * The tables listed in $tables are expected to currently not exist, so dropMockTables()
+ * should be called first.
*
* @param IMaintainableDatabase $db
* @param array $tables
throw new LogicException( 'No original table prefix know, cannot restore tables!' );
}
- $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
+ $originalTables = $this->listOriginalTables( $db );
$tables = array_intersect( $tables, $originalTables );
$dbClone = new CloneDatabase( $db, $tables, $db->tablePrefix(), $db->_originalTablePrefix );
continue;
}
+ if ( !$db->tableExists( $tbl ) ) {
+ continue;
+ }
+
if ( $truncate ) {
$db->query( 'TRUNCATE TABLE ' . $db->tableName( $tbl ), __METHOD__ );
} else {
[ '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', true ],
+ [ 'MediaWiki:Foo.json', true ],
[ 'MediaWiki:Foo.css', true ],
[ 'MediaWiki:Foo.JS', false ],
+ [ 'MediaWiki:Foo.JSON', false ],
[ 'MediaWiki:Foo.CSS', false ],
[ 'MediaWiki:Foo/bar.css', true ],
[ 'MediaWiki:Foo.css.xxx', false ],
[ 'User:Foo.js', false ],
[ 'User:Foo/bar.js', true ],
[ 'User:Foo/bar.JS', false ],
+ [ 'User:Foo/bar.json', true ],
+ [ 'User:Foo/bar.JSON', false ],
[ 'User:Foo/bar.css', true ],
[ '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 ],
[ 'Help:Foo.css', false ],
[ 'User:Foo', false ],
[ 'User:Foo.js', false ],
+ [ 'User:Foo.json', false ],
[ 'User:Foo.css', false ],
[ 'User:Foo/bar.js', false ],
+ [ 'User:Foo/bar.json', false ],
[ 'User:Foo/bar.css', 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 ],
$this->runConfigEditPermissions(
[ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ]
);
}
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testJsonConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->userName . '/test.json' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomjsonprotected', 'bogus' ] ]
+ );
+ }
+
/**
* @todo This test method should be split up into separate test methods and
* data providers
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'mycustomcssprotected', 'bogus' ] ]
);
}
$this->runConfigEditPermissions(
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customjsprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ]
);
}
+ /**
+ * @todo This test method should be split up into separate test methods and
+ * data providers
+ * @covers Title::checkUserConfigPermissions
+ */
+ public function testOtherJsonConfigEditPermissions() {
+ $this->setUser( $this->userName );
+
+ $this->setTitle( NS_USER, $this->altUserName . '/test.json' );
+ $this->runConfigEditPermissions(
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'customjsonprotected', 'bogus' ] ]
+ );
+ }
+
/**
* @todo This test method should be split up into separate test methods and
* data providers
$this->runConfigEditPermissions(
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ],
[ [ 'badaccess-group0' ], [ 'customcssprotected', 'bogus' ] ]
);
}
$this->runConfigEditPermissions(
[ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
+ [ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ],
[ [ 'badaccess-group0' ] ]
);
protected function runConfigEditPermissions(
$resultNone,
$resultMyCss,
+ $resultMyJson,
$resultMyJs,
$resultUserCss,
+ $resultUserJson,
$resultUserJs
) {
$this->setUserPerm( '' );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
$this->assertEquals( $resultMyCss, $result );
+ $this->setUserPerm( 'editmyuserjson' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultMyJson, $result );
+
$this->setUserPerm( 'editmyuserjs' );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
$this->assertEquals( $resultMyJs, $result );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
$this->assertEquals( $resultUserCss, $result );
+ $this->setUserPerm( 'edituserjson' );
+ $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
+ $this->assertEquals( $resultUserJson, $result );
+
$this->setUserPerm( 'edituserjs' );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
$this->assertEquals( $resultUserJs, $result );
- $this->setUserPerm( [ 'edituserjs', 'editusercss' ] );
+ $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] );
$result = $this->title->getUserPermissionsErrors( 'bogus', $this->user );
$this->assertEquals( [ [ 'badaccess-group0' ] ], $result );
}
$wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
$wgContentHandlers["testing-nontext"] = 'DummyNonTextContentHandler';
+ $wgContentHandlers["testing-serialize-error"] =
+ 'DummySerializeErrorContentHandler';
MWNamespace::clearCaches();
$wgContLang->resetNamespaces(); # reset namespace cache
// Validate API result data
$this->assertArrayHasKey( 'edit', $apiResult );
$this->assertArrayHasKey( 'result', $apiResult['edit'] );
- $this->assertEquals( 'Success', $apiResult['edit']['result'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
$this->assertArrayHasKey( 'new', $apiResult['edit'] );
$this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
'text' => 'some text',
] );
- $this->assertEquals( 'Success', $data[0]['edit']['result'] );
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
$this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
$this->assertArrayHasKey( 'nochange', $data[0]['edit'] );
'text' => 'different text'
] );
- $this->assertEquals( 'Success', $data[0]['edit']['result'] );
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
$this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
$this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] );
'title' => $name,
'text' => $text, ] );
- $this->assertEquals( 'Success', $re['edit']['result'] ); // sanity
+ $this->assertSame( 'Success', $re['edit']['result'] ); // sanity
}
// -- try append/prepend --------------------------------------------
'title' => $name,
$op . 'text' => $append, ] );
- $this->assertEquals( 'Success', $re['edit']['result'] );
+ $this->assertSame( 'Success', $re['edit']['result'] );
// -- validate -----------------------------------------------------
$page = new WikiPage( Title::newFromText( $name ) );
$text = $content->getNativeData();
- $this->assertEquals( $expected, $text );
+ $this->assertSame( $expected, $text );
}
/**
'section' => '1',
'text' => "==section 1==\nnew content 1",
] );
- $this->assertEquals( 'Success', $re['edit']['result'] );
+ $this->assertSame( 'Success', $re['edit']['result'] );
$newtext = WikiPage::factory( Title::newFromText( $name ) )
->getContent( Revision::RAW )
->getNativeData();
- $this->assertEquals( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );
+ $this->assertSame( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );
// Test that we raise a 'nosuchsection' error
try {
'summary' => 'header',
] );
- $this->assertEquals( 'Success', $re['edit']['result'] );
+ $this->assertSame( 'Success', $re['edit']['result'] );
// Check the page text is correct
$text = WikiPage::factory( Title::newFromText( $name ) )
->getContent( Revision::RAW )
->getNativeData();
- $this->assertEquals( "== header ==\n\ntest", $text );
+ $this->assertSame( "== header ==\n\ntest", $text );
// Now on one that does
$this->assertTrue( Title::newFromText( $name )->exists() );
'summary' => 'header',
] );
- $this->assertEquals( 'Success', $re2['edit']['result'] );
+ $this->assertSame( 'Success', $re2['edit']['result'] );
$text = WikiPage::factory( Title::newFromText( $name ) )
->getContent( Revision::RAW )
->getNativeData();
- $this->assertEquals( "== header ==\n\ntest\n\n== header ==\n\ntest", $text );
+ $this->assertSame( "== header ==\n\ntest\n\n== header ==\n\ntest", $text );
}
/**
'redirect' => true,
], null, self::$users['sysop']->getUser() );
- $this->assertEquals( 'Success', $re['edit']['result'],
+ $this->assertSame( 'Success', $re['edit']['result'],
"no problems expected when following redirect" );
}
'section' => 'new',
], null, self::$users['sysop']->getUser() );
- $this->assertEquals( 'Success', $re['edit']['result'],
+ $this->assertSame( 'Success', $re['edit']['result'],
"no edit conflict expected here" );
}
'redirect' => true,
], null, self::$users['sysop']->getUser() );
- $this->assertEquals( 'Success', $re['edit']['result'],
+ $this->assertSame( 'Success', $re['edit']['result'],
"no edit conflict expected here" );
}
// Validate API result data
$this->assertArrayHasKey( 'edit', $apiResult );
$this->assertArrayHasKey( 'result', $apiResult['edit'] );
- $this->assertEquals( 'Success', $apiResult['edit']['result'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
$this->assertArrayHasKey( 'new', $apiResult['edit'] );
$this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
// validate resulting revision
$page = WikiPage::factory( Title::newFromText( $name ) );
- $this->assertEquals( "testing-nontext", $page->getContentModel() );
- $this->assertEquals( $data, $page->getContent()->serialize() );
+ $this->assertSame( "testing-nontext", $page->getContentModel() );
+ $this->assertSame( $data, $page->getContent()->serialize() );
}
/**
// Check success
$this->assertArrayHasKey( 'edit', $apiResult );
$this->assertArrayHasKey( 'result', $apiResult['edit'] );
- $this->assertEquals( 'Success', $apiResult['edit']['result'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
// Content model is wikitext
- $this->assertEquals( 'wikitext', $apiResult['edit']['contentmodel'] );
+ $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
// Convert the page to JSON
$apiResult = $this->doApiRequestWithToken( [
// Check success
$this->assertArrayHasKey( 'edit', $apiResult );
$this->assertArrayHasKey( 'result', $apiResult['edit'] );
- $this->assertEquals( 'Success', $apiResult['edit']['result'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
- $this->assertEquals( 'json', $apiResult['edit']['contentmodel'] );
+ $this->assertSame( 'json', $apiResult['edit']['contentmodel'] );
$apiResult = $this->doApiRequestWithToken( [
'action' => 'edit',
// Check success
$this->assertArrayHasKey( 'edit', $apiResult );
$this->assertArrayHasKey( 'result', $apiResult['edit'] );
- $this->assertEquals( 'Success', $apiResult['edit']['result'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
// Check that the contentmodel is back to wikitext now.
- $this->assertEquals( 'wikitext', $apiResult['edit']['contentmodel'] );
+ $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() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/x-wiki',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testUnsupportedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Unrecognized value for parameter "contentformat": nonexistent format.' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentformat' => 'nonexistent format',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testMismatchedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The requested format text/plain is not supported for content ' .
+ "model wikitext used by $name." );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/plain',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testUndoToInvalidRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId = $this->editPage( $name, 'Some text' )->value['revision']
+ ->getId();
+ $revId++;
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ '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.
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+ $revId3 = $this->editPage( $name, '3' )->value['revision']->getId();
+
+ // Make the middle revision disappear
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'revision', [ 'rev_id' => $revId2 ], __METHOD__ );
+ $dbw->update( 'revision', [ 'rev_parent_id' => $revId1 ],
+ [ 'rev_id' => $revId3 ], __METHOD__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId3,
+ 'undoafter' => $revId2,
+ ] );
+ }
+
+ /**
+ * Tests what happens if the undo parameter is a valid revision, but
+ * undoafter is hidden (rev_deleted).
+ */
+ public function testUndoAfterToHiddenRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ // Hide the middle revision
+ $list = RevisionDeleter::createList( 'revision',
+ RequestContext::getMain(), $titleObj, [ $revId1 ] );
+ $list->setVisibility( [
+ 'value' => [ Revision::DELETED_TEXT => 1 ],
+ 'comment' => 'Bye-bye',
+ ] );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId1." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ '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() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ // Now monkey with the timestamp
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'revision',
+ [ 'rev_timestamp' => wfTimestamp( TS_MW, time() - 86400 ) ],
+ [ 'rev_id' => $revId1 ],
+ __METHOD__
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+
+ $text = ( new WikiPage( $titleObj ) )->getContent()->getNativeData();
+
+ // This is wrong! It should be 1. But let's test for our incorrect
+ // behavior for now, so if someone fixes it they'll fix the test as
+ // well to expect 1. If we disabled the test, it might stay disabled
+ // even once the bug is fixed, which would be a shame.
+ $this->assertSame( '2', $text );
+ }
+
+ public function testUndoWithConflicts() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The edit could not be undone due to conflicting intermediate edits.' );
+
+ $this->editPage( $name, '1' );
+
+ $revId = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->editPage( $name, '3' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '3', $text );
+ }
+
+ /**
+ * undoafter is supposed to be less than undo. If not, we reverse their
+ * meaning, so that the two are effectively interchangeable.
+ */
+ public function testReversedUndoAfter() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, '0' );
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId1,
+ 'undoafter' => $revId2,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '1', $text );
+ }
+
+ public function testUndoToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( "$name-1", 'Some text' );
+ $revId = $this->editPage( "$name-1", 'Some more text' )
+ ->value['revision']->getId();
+
+ $this->editPage( "$name-2", 'Some text' );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId,
+ ] );
+ }
+
+ public function testUndoAfterToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId1 = $this->editPage( "$name-1", 'Some text' )
+ ->value['revision']->getId();
+
+ $revId2 = $this->editPage( "$name-2", 'Some text' )
+ ->value['revision']->getId();
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId1 is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+ }
+
+ public function testMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( 'Some text' ),
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'md5' => md5( 'Alert: ' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text', $text );
+ }
+
+ public function testMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' is nice',
+ 'md5' => md5( ' is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Some text is nice', $text );
+ }
+
+ public function testMd5PrependAndAppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'appendtext' => ' is nice',
+ 'md5' => md5( 'Alert: is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text is nice', $text );
+ }
+
+ public function testIncorrectMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( '' ),
+ ] );
+ }
+
+ public function testIncorrectMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'Some ' ),
+ ] );
+ }
+
+ public function testIncorrectMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'text' ),
+ ] );
+ }
+
+ public function testCreateOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The article you tried to create has been created already.' );
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some more text',
+ 'createonly' => '',
+ ] );
+ } finally {
+ // Validate that content was not changed
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Some text', $text );
+ }
+ }
+
+ public function testNoCreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'nocreate' => '',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ /**
+ * 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->setExpectedException( ApiUsageException::class,
+ "Can't append to pages using content model testing-nontext." );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ 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() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testAppendInMediaWikiNamespaceWithSerializationError() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ 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() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Initial content\n\n== New section ==", $text );
+ }
+
+ public function testAppendNewSectionWithInvalidContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Sections are not supported for content model text.' );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ 'contentmodel' => 'text',
+ ] );
+ }
+
+ public function testAppendNewSectionWithTitle() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( '/* My section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== Add new section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ // EditPage actually assumes the summary is the section name here
+ $this->assertSame( '/* Add new section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithTitleAndSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( 'Add new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendToSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "== Section 1 ==\n\nContent\n\n" .
+ "== Section 2 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "== Section 1 ==\n\nContent and more content\n\n" .
+ "== Section 2 ==\n\nFascinating!", $text );
+ }
+
+ public function testAppendToFirstSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "Content\n\n== Section 1 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '0',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Content and more content\n\n== Section 1 ==\n\n" .
+ "Fascinating!", $text );
+ }
+
+ public function testAppendToNonexistentSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class, 'There is no section 1.' );
+
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditMalformedSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different content',
+ 'section' => 'It is unlikely that this is valid',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditWithStartTimestamp() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $this->setExpectedException( ApiUsageException::class,
+ 'The page has been deleted since you fetched its timestamp.' );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ ] );
+ } finally {
+ $this->assertFalse( $pageObj->exists() );
+ }
+ }
+
+ public function testEditMinor() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'minor' => '',
+ ] );
+
+ $revisionStore = \MediaWiki\MediaWikiServices::getInstance()->getRevisionStore();
+ $revision = $revisionStore->getRevisionByTitle( Title::newFromText( $name ) );
+ $this->assertTrue( $revision->isMinor() );
+ }
+
+ public function testEditRecreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ 'recreate' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditWatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'watch' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+
+ public function testEditUnwatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+ $titleObj = Title::newFromText( $name );
+
+ $user->addWatch( $titleObj );
+
+ $this->assertFalse( $titleObj->exists() );
+ $this->assertTrue( $user->isWatched( $titleObj ) );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'unwatch' => '',
+ ] );
+
+ $this->assertTrue( $titleObj->exists() );
+ $this->assertFalse( $user->isWatched( $titleObj ) );
+ }
+
+ public function testEditWithTag() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $revId = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] )[0]['edit']['newrevid'];
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ 'change_tag', 'ct_tag', [ 'ct_rev_id' => $revId ], __METHOD__ ) );
+ }
+
+ public function testEditWithoutTagPermission() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ ChangeTags::defineTag( 'custom tag' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHook() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHookWithCustomOutput() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function ( $unused1, $unused2, &$r ) {
+ $r['msg'] = 'Some message';
+ return false;
+ } );
+
+ $result = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ Wikimedia\restoreWarnings();
+
+ $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ],
+ $result[0]['edit'] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditAbortedByEditPageHookWithResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function ( $unused1, $unused2, Status $status ) {
+ $status->apiHookResult = [ 'msg' => 'A message for you!' ];
+ return false;
+ } );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertSame( [ 'edit' => [ 'msg' => 'A message for you!',
+ 'result' => 'Failure' ] ], $res[0] );
+ }
+
+ public function testEditAbortedByEditPageHookWithNoResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditWhileBlocked() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You have been blocked from editing.' );
+
+ $block = new Block( [
+ 'address' => self::$users['sysop']->getUser()->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ ] );
+ $block->insert();
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $block->delete();
+ self::$users['sysop']->getUser()->clearInstanceCache();
+ }
+ }
+
+ public function testEditWhileReadOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The wiki is currently in read-only mode.' );
+
+ $svc = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $svc->setReason( "Read-only for testing" );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $svc->setReason( false );
+ }
+ }
+
+ public function testCreateImageRedirectAnon() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ // @todo When ApiTestCase supports anonymous users, this exception
+ // should no longer be thrown, and the test can then be updated to test
+ // for the actual expected behavior.
+ $this->setExpectedException( ApiUsageException::class,
+ 'Invalid CSRF token.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'logout',
+ ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ] );
+ }
+
+ public function testCreateImageRedirectLoggedIn() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to create image redirects." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'upload' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ] );
+ }
+
+ public function testTooBigEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The content you supplied exceeds the article size limit of 1 kilobyte.' );
+
+ $this->setMwGlobals( 'wgMaxArticleSize', 1 );
+
+ $text = str_repeat( '!', 1025 );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text,
+ ] );
+ }
+
+ public function testProhibitedAnonymousEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ // @todo See comment in testCreateImageRedirectAnon
+ $this->setExpectedException( ApiUsageException::class,
+ 'Invalid CSRF token.' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ '*' => [ 'edit' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'logout',
+ ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ }
+
+ public function testProhibitedChangeContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to change the content model of a page." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'editcontentmodel' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'contentmodel' => 'json',
+ ] );
}
}
$this->assertEquals( $expected, $client->getHeadHtml() );
}
+ /**
+ * Confirm that 'target' is passed down to the startup module's load url.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => 'example' ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback&target=example"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * Confirm that a null 'target' is the same as no target.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithNullTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => null ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
/**
* @covers ResourceLoaderClientHtml::getBodyHtml
* @covers ResourceLoaderClientHtml::getLoad
--- /dev/null
+<?php
+
+/**
+ * A simple {@link MessageLocalizer} implementation for use in tests.
+ * By default, it sets the message language to 'qqx',
+ * to make the tests independent of the wiki configuration.
+ *
+ * @author Lucas Werkmeister
+ * @license GPL-2.0-or-later
+ */
+class MockMessageLocalizer implements MessageLocalizer {
+
+ /**
+ * @var string|null
+ */
+ private $languageCode;
+
+ /**
+ * @param string|null $languageCode The language code to use for messages by default.
+ * You can specify null to use the user language,
+ * but this is not recommended as it may make your tests depend on the wiki configuration.
+ */
+ public function __construct( $languageCode = 'qqx' ) {
+ $this->languageCode = $languageCode;
+ }
+
+ /**
+ * Get a Message object.
+ * Parameters are the same as {@link wfMessage()}.
+ *
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,...
+ * @return Message
+ */
+ public function msg( $key ) {
+ $args = func_get_args();
+
+ /** @var Message $message */
+ $message = call_user_func_array( 'wfMessage', $args );
+
+ if ( $this->languageCode !== null ) {
+ $message->inLanguage( $this->languageCode );
+ }
+
+ return $message;
+ }
+
+}
class DummyContentHandlerForTesting extends ContentHandler {
- public function __construct( $dataModel ) {
- parent::__construct( $dataModel, [ DummyContentForTesting::MODEL_ID ] );
+ public function __construct( $dataModel, $formats = [ DummyContentForTesting::MODEL_ID ] ) {
+ parent::__construct( $dataModel, $formats );
}
/**
--- /dev/null
+<?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
+ */
+
+/**
+ * A dummy content handler that will throw on an attempt to serialize content.
+ */
+class DummySerializeErrorContentHandler extends DummyContentHandlerForTesting {
+
+ public function __construct( $dataModel ) {
+ parent::__construct( $dataModel, [ "testing-serialize-error" ] );
+ }
+
+ /**
+ * @see ContentHandler::unserializeContent
+ *
+ * @param string $blob
+ * @param string $format
+ *
+ * @return Content
+ */
+ public function unserializeContent( $blob, $format = null ) {
+ throw new MWContentSerializationException( 'Could not unserialize content' );
+ }
+
+ /**
+ * @see ContentHandler::supportsDirectEditing
+ *
+ * @return bool
+ *
+ * @todo Should this be in the parent class?
+ */
+ public function supportsDirectApiEditing() {
+ return true;
+ }
+
+}
<?php
+use Wikimedia\Rdbms\IMaintainableDatabase;
/**
* @covers MediaWikiTestCase
public static $hasRun = false;
- public function getSchemaOverrides() {
+ public function getSchemaOverrides( IMaintainableDatabase $db ) {
return [
- [ 'imagelinks', 'MediaWikiTestCaseTestTable' ],
- [ __DIR__ . '/MediaWikiTestCaseSchemaTest.sql' ]
+ 'create' => [ 'MediaWikiTestCaseTestTable', 'imagelinks' ],
+ 'drop' => [ 'oldimage' ],
+ 'alter' => [ 'pagelinks' ],
+ 'scripts' => [ __DIR__ . '/MediaWikiTestCaseSchemaTest.sql' ]
];
}
$this->assertTrue( self::$hasRun );
}
- public function testSchemaExtension() {
- // make sure we can use the MediaWikiTestCaseTestTable table
-
- $input = [ 'id' => '5', 'name' => 'Test' ];
-
- $this->db->insert(
- 'MediaWikiTestCaseTestTable',
- $input
- );
-
- $output = $this->db->selectRow( 'MediaWikiTestCaseTestTable', array_keys( $input ), [] );
- $this->assertEquals( (object)$input, $output );
+ public function testTableWasCreated() {
+ // Make sure MediaWikiTestCaseTestTable was created.
+ $this->assertTrue( $this->db->tableExists( 'MediaWikiTestCaseTestTable' ) );
}
- public function testSchemaOverride() {
- // make sure we can use the il_frobniz field
-
- $input = [
- 'il_from' => '7',
- 'il_from_namespace' => '0',
- 'il_to' => 'Foo.jpg',
- 'il_frobniz' => 'Xyzzy',
- ];
+ public function testTableWasDropped() {
+ // Make sure oldimage was dropped
+ $this->assertFalse( $this->db->tableExists( 'oldimage' ) );
+ }
- $this->db->insert(
- 'imagelinks',
- $input
- );
+ public function testTableWasOverriden() {
+ // Make sure imagelinks was overwritten
+ $this->assertTrue( $this->db->tableExists( 'imagelinks' ) );
+ $this->assertTrue( $this->db->fieldExists( 'imagelinks', 'il_frobnitz' ) );
+ }
- $output = $this->db->selectRow( 'imagelinks', array_keys( $input ), [] );
- $this->assertEquals( (object)$input, $output );
+ public function testTableWasAltered() {
+ // Make sure pagelinks was altered
+ $this->assertTrue( $this->db->tableExists( 'pagelinks' ) );
+ $this->assertTrue( $this->db->fieldExists( 'pagelinks', 'pl_frobnitz' ) );
}
}
$this->assertTrue( MediaWikiTestCaseSchema1Test::$hasRun );
}
- public function testSchemaExtension() {
+ public function testCreatedTableWasRemoved() {
// Make sure MediaWikiTestCaseTestTable created by MediaWikiTestCaseSchema1Test
// was dropped before executing MediaWikiTestCaseSchema2Test.
$this->assertFalse( $this->db->tableExists( 'MediaWikiTestCaseTestTable' ) );
}
- public function testSchemaOverride() {
- // Make sure imagelinks modified by MediaWikiTestCaseSchema1Test
+ public function testDroppedTableWasRestored() {
+ // Make sure oldimage that was dropped by MediaWikiTestCaseSchema1Test
+ // was restored before executing MediaWikiTestCaseSchema2Test.
+ $this->assertTrue( $this->db->tableExists( 'oldimage' ) );
+ }
+
+ public function testOverridenTableWasRestored() {
+ // Make sure imagelinks overwritten by MediaWikiTestCaseSchema1Test
// was restored to the original schema before executing MediaWikiTestCaseSchema2Test.
$this->assertTrue( $this->db->tableExists( 'imagelinks' ) );
- $this->assertFalse( $this->db->fieldExists( 'imagelinks', 'il_frobniz' ) );
+ $this->assertFalse( $this->db->fieldExists( 'imagelinks', 'il_frobnitz' ) );
+ }
+
+ public function testAlteredTableWasRestored() {
+ // Make sure pagelinks altered by MediaWikiTestCaseSchema1Test
+ // was restored to the original schema before executing MediaWikiTestCaseSchema2Test.
+ $this->assertTrue( $this->db->tableExists( 'pagelinks' ) );
+ $this->assertFalse( $this->db->fieldExists( 'pagelinks', 'pl_frobnitz' ) );
}
}
il_from int NOT NULL DEFAULT 0,
il_from_namespace int NOT NULL DEFAULT 0,
il_to varchar(127) NOT NULL DEFAULT '',
- il_frobniz varchar(127) NOT NULL DEFAULT 'FROB',
+ il_frobnitz varchar(127) NOT NULL DEFAULT 'FROB',
PRIMARY KEY (il_from,il_to)
) /*$wgDBTableOptions*/;
+
+ALTER TABLE /*_*/pagelinks
+ADD pl_frobnitz varchar(127) NOT NULL DEFAULT 'FROB';
+
+DROP TABLE /*_*/oldimage;
assert.notEqual( result, result2, 'different when called multiple times' );
} );
+ QUnit.test( 'stickyRandomId', function ( assert ) {
+ var result = mw.user.stickyRandomId(),
+ result2 = mw.user.stickyRandomId();
+ assert.equal( typeof result, 'string', 'type' );
+ assert.strictEqual( /^[a-f0-9]{16}$/.test( result ), true, '16 HEX symbols string' );
+ assert.equal( result2, result, 'sticky' );
+ } );
+
QUnit.test( 'sessionId', function ( assert ) {
var result = mw.user.sessionId(),
result2 = mw.user.sessionId();