From: jenkins-bot Date: Fri, 22 Jan 2016 19:54:32 +0000 (+0000) Subject: Merge "mw.Upload.BookletLayout: Don't explode when the API call fails with 'exception'" X-Git-Tag: 1.31.0-rc.0~8248 X-Git-Url: https://git.cyclocoop.org/%20%27.%28%24debut%20%20%20%24par_page%29.%27?a=commitdiff_plain;h=3301e78e5a2e5662952c0564f830a492743f9844;hp=a7ab9222e975af7c9ffc49c9039abe96eb650afc;p=lhc%2Fweb%2Fwiklou.git Merge "mw.Upload.BookletLayout: Don't explode when the API call fails with 'exception'" --- diff --git a/CREDITS b/CREDITS index 76d210770f..fe7b81ea9b 100644 --- a/CREDITS +++ b/CREDITS @@ -169,6 +169,7 @@ following names for their contribution to the product. * Louperivois * Lucas Garczewski * Luigi Corsaro +* Luke Faraone * Lupo * Madman * Manuel Menal diff --git a/Gruntfile.js b/Gruntfile.js index 946b6522f4..354f0483b6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -56,8 +56,9 @@ module.exports = function ( grunt ) { included: true, served: false } ], + logLevel: 'DEBUG', frameworks: [ 'qunit' ], - reporters: [ 'dots' ], + reporters: [ 'progress' ], singleRun: true, autoWatch: false, // Some tests in extensions don't yield for more than the default 10s (T89075) diff --git a/HISTORY b/HISTORY index 0410bd5672..e57d346316 100644 --- a/HISTORY +++ b/HISTORY @@ -1,6 +1,14 @@ Change notes from older releases. For current info see RELEASE-NOTES-1.27. -== MediaWiki 1.26 == += MediaWiki 1.26 = + +== MediaWiki 1.26.2 == + +This is a maintenance release of the MediaWiki 1.26 branch. + +=== Changes since 1.26.1 === +* (T121892) Fix fatal error on some Special pages, introduced in 1.26.1. + == MediaWiki 1.26.1 == This is a maintenance release of the MediaWiki 1.26 branch. @@ -26,6 +34,8 @@ This is a maintenance release of the MediaWiki 1.26 branch. * Fix issue that breaks HHVM Repo Authorative mode. * (T120267) Work around APCu memory corruption bug +== MediaWiki 1.26.0 == + === Configuration changes in 1.26 === * $wgPasswordResetRoutes['email'] = true by default. * $wgEnableParserCache was deprecated, set $wgParserCacheType to CACHE_NONE @@ -115,7 +125,7 @@ This is a maintenance release of the MediaWiki 1.26 branch. documentation for mw.Upload.Dialog, mw.Upload.BookletLayout and its subclasses for more information. -== extension.json changes in 1.26 == +=== extension.json changes in 1.26 === * (T99344) The extension.json schema is now versioned. All extensions and skins should set a "manifest_version" property corresponding to the schema version they were written for. The only supported version @@ -268,7 +278,14 @@ changes to languages because of Phabricator reports. * $wgDeferredUpdateList was removed. * DeferredUpdates::addHTMLCacheUpdate() was removed. -== MediaWiki 1.25 == += MediaWiki 1.25 = + +== MediaWiki 1.25.5 == + +This is a maintenance release of the MediaWiki 1.25 branch. + +=== Changes since 1.25.4 === +* (T121892) Fix fatal error on some Special pages, introduced in 1.25.4. == MediaWiki 1.25.4 == @@ -348,6 +365,8 @@ This is a bug fix release of the MediaWiki 1.25 branch. === Changes since 1.25 === * (T100351) Fix syntax errors in extension.json of ConfirmEdit extension +== MediaWiki 1.25.0 == + === Configuration changes in 1.25 === * $wgPageShowWatchingUsers was removed. * $wgLocalVirtualHosts has been added to replace $wgConf->localVHosts. @@ -850,55 +869,20 @@ changes to languages because of Bugzilla reports. loadedScripts object, from wikibits.js (deprecated since 1.17) now emit warnings through mw.log.warn when accessed. += MediaWiki 1.24 = -== Compatibility == - -MediaWiki 1.25 requires PHP 5.3.3 or later. There is experimental support for -HHVM 3.3.0. - -MySQL is the recommended DBMS. PostgreSQL or SQLite can also be used, but -support for them is somewhat less mature. There is experimental support for -Oracle and Microsoft SQL Server. +== MediaWiki 1.24.6 == -The supported versions are: +This is a maintenance release of the MediaWiki 1.24 branch. -* MySQL 5.0.3 or later -* PostgreSQL 8.3 or later -* SQLite 3.3.7 or later -* Oracle 9.0.1 or later -* Microsoft SQL Server 2005 (9.00.1399) - -== Upgrading == - -1.25 has several database changes since 1.24, and will not work without schema -updates. Note that due to changes to some very large tables like the revision -table, the schema update may take quite long (minutes on a medium sized site, -many hours on a large site). - -If upgrading from before 1.11, and you are using a wiki as a commons -repository, make sure that it is updated as well. Otherwise, errors may arise -due to database schema changes. - -If upgrading from before 1.7, you may want to run refreshLinks.php to ensure -new database fields are filled with data. - -If you are upgrading from MediaWiki 1.4.x or earlier, you should upgrade to -1.5 first. The upgrade script maintenance/upgrade1_5.php has been removed -with MediaWiki 1.21. - -Don't forget to always back up your database before upgrading! - -See the file UPGRADE for more detailed upgrade instructions. - -For notes on 1.24.x and older releases, see HISTORY. - -== MediaWiki 1.24 == +=== Changes since 1.24.5 === +* (T121892) Fix fatal error on some Special pages, introduced in 1.24.5. == MediaWiki 1.24.5 == This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.24.4 == +=== Changes since 1.24.4 === * (T117899) SECURITY: $wgArticlePath can no longer be set to relative paths that do not begin with a slash. This enabled trivial XSS attacks. Configuration values such as "http://my.wiki.com/wiki/$1" are fine, as are @@ -920,7 +904,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. This is a security and maintenance release of the MediaWiki 1.24 branch. -== Changes since 1.24.3 == +=== Changes since 1.24.3 === * (T91653) Minimal PSR-3 debug logger to support backports from 1.25+. * (T68650) Fix indexing of moved pages with PostgreSQL. Requires running @@ -935,7 +919,7 @@ This is a security and maintenance release of the MediaWiki 1.24 branch. This is a security and maintenance release of the MediaWiki 1.24 branch. -== Changes since 1.24.2 == +=== Changes since 1.24.2 === * (T94116) SECURITY: Compare API watchlist token in constant time * (T97391) SECURITY: Escape error message strings in thumb.php @@ -949,7 +933,7 @@ This is a security and maintenance release of the MediaWiki 1.24 branch. This is a security and maintenance release of the MediaWiki 1.24 branch. -== Changes since 1.24.1 == +=== Changes since 1.24.1 === * (T85848, T71210) SECURITY: Don't parse XMP blocks that contain XML entities, to prevent various DoS attacks. @@ -973,7 +957,7 @@ This is a security and maintenance release of the MediaWiki 1.24 branch. This is a security and maintenance release of the MediaWiki 1.24 branch. -== Changes since 1.24.0 == +=== Changes since 1.24.0 === * (bug T76686) [SECURITY] thumb.php outputs wikitext message as raw HTML, which could lead to xss. Permission to edit MediaWiki namespace is required to @@ -986,6 +970,8 @@ This is a security and maintenance release of the MediaWiki 1.24 branch. * (bug T76168) OutputPage: Add accessors for some protected properties. * (bug T74834) Make 1.24 branch directly installable under PostgreSQL. +== MediaWiki 1.24.0 == + === Configuration changes in 1.24 === * MediaWiki will no longer run if register_globals is enabled. It has been deprecated for 5 years now, and was removed in PHP 5.4. For more information @@ -1678,14 +1664,20 @@ of files that are no longer available follows. * skins/common/images/icons/fileicon.png * skins/common/images/ksh/button_S_italic.png += MediaWiki 1.23 = + +== MediaWiki 1.23.13 == -== MediaWiki 1.23 == +This is a maintenance release of the MediaWiki 1.23 branch. + +=== Changes since 1.23.12 === +* (T121892) Fix fatal errors on some Special pages, introduced in 1.23.12. == MediaWiki 1.23.12 == This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.11 == +=== Changes since 1.23.11 === * (T117899) SECURITY: $wgArticlePath can no longer be set to relative paths that do not begin with a slash. This enabled trivial XSS attacks. Configuration values such as "http://my.wiki.com/wiki/$1" are fine, as are @@ -1706,7 +1698,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.10 == +=== Changes since 1.23.10 === * (T91850) SECURITY: Add throttle check in ApiUpload and SpecialUpload * (T91203, T91205) SECURITY: API: Improve validation in chunked uploading @@ -1716,7 +1708,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.9 == +=== Changes since 1.23.9 === * (T94116) SECURITY: Compare API watchlist token in constant time * (T97391) SECURITY: Escape error message strings in thumb.php @@ -1731,7 +1723,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.8 == +=== Changes since 1.23.8 === * (T85848, T71210) SECURITY: Don't parse XMP blocks that contain XML entities, to prevent various DoS attacks. @@ -1744,14 +1736,14 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. prevent XSS and protect viewer's privacy. * (bug T68650) Fix indexing of moved pages with PostgreSQL. Requires running update.php to fix. -* (bug T70087) Fix Special:ActiveUsers page for installations using +* (bug T70087) Fix Special:ActiveUsers page for installations using PostgreSQL. == MediaWiki 1.23.8 == This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.7 == +=== Changes since 1.23.7 === * (bug T76686) [SECURITY] thumb.php outputs wikitext message as raw HTML, which could lead to xss. Permission to edit MediaWiki namespace is required to @@ -1765,7 +1757,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. This is a security and maintenance release of the MediaWiki 1.23 branch. -== Changes since 1.23.6 == +=== Changes since 1.23.6 === * (bugs 66776, 71478) SECURITY: User PleaseStand reported a way to inject code into API clients that used format=php to process pages that underwent flash @@ -1869,6 +1861,7 @@ This is a security and maintenance release of the MediaWiki 1.23 branch. like only extracting the tail of the file partially or not at all. * (bug 66182) Removed -x flag on some php files. +== MediaWiki 1.23.0 == === Configuration changes in 1.23 === * (bug 13250) Restored method for clearing a watchlist in web UI @@ -2337,7 +2330,7 @@ changes to languages because of Bugzilla reports. ==== Removed globals ==== * $wgBetterDirectionality (deprecated in 1.18) -== MediaWiki 1.22 == += MediaWiki 1.22 = == MediaWiki 1.22.15 == @@ -2493,6 +2486,8 @@ This is a security and maintenance release of the MediaWiki 1.22 branch. * (bug 47055) Changed FOR UPDATE handling in Postgresql * (bug 57026) Avoid extra parsing in prepareContentForEdit() +== MediaWiki 1.22.0 == + === Configuration changes in 1.22 === * $wgRedirectScript was removed. It was unused. * Removed $wgLocalMessageCacheSerialized, it is now always true. @@ -2902,7 +2897,7 @@ This is a security and maintenance release of the MediaWiki 1.22 branch. file repositories, and related ForeignAPIRepo methods getInfo and getApiUrl. * The new query module list=allfileusages to enumerate file usages was added. -=== Languages updated in 1.22=== +=== Languages updated in 1.22 === MediaWiki supports over 350 languages. Many localisations are updated regularly. Below only new and removed languages are listed, as well as @@ -3020,7 +3015,7 @@ changes to languages because of Bugzilla reports. * mediawiki.util: mw.util.wikiGetlink has been renamed to getUrl. (The old name still works, but is deprecated.) -== MediaWiki 1.21 == += MediaWiki 1.21 = == MediaWiki 1.21.11 == This is a security and maintenance release of the MediaWiki 1.21 branch. @@ -3108,6 +3103,8 @@ This is a maintenance release of the MediaWiki 1.21 branch. * A problem with the Oracle SQL table creation was fixed. * (PdfHandler extension) Fix warning if pdfinfo fails but pdftext succeeds. +== MediaWiki 1.21.0 == + === Configuration changes in 1.21 === * (bug 29374) $wgVectorUseSimpleSearch is now enabled by default. * Deprecated $wgAllowRealName is removed. Use $wgHiddenPrefs[] = 'realname' @@ -3436,7 +3433,7 @@ changes to languages because of Bugzilla reports. * BREAKING CHANGE: (bug 38244) Removed the mediawiki.api.titleblacklist module and moved it to the TitleBlacklist extension. -== MediaWiki 1.20 == += MediaWiki 1.20 = == MediaWiki 1.20.8 == This is a security release of the MediaWiki 1.20 branch. @@ -3489,7 +3486,7 @@ This is a security release of the MediaWiki 1.20 branch. == MediaWiki 1.20.3 == This is a security and maintenance release of the MediaWiki 1.20 branch. -== MediaWiki 1.20.2 == +=== Changes since MediaWiki 1.20.2 === * New preference type - 'api'. Preferences of this type are not shown on Special:Preferences, but are still available via the action=options API. (Unbreaks MLEB.) * (bug 44010) Context is passed to UserGetLanguageObject. * The recursion guard on RequestContext::getLanguage() was weakened. @@ -3503,14 +3500,14 @@ This is a security and maintenance release of the MediaWiki 1.20 branch. == MediaWiki 1.20.2 == This is a maintenance release of the MediaWiki 1.20 branch -== MediaWiki 1.20.1 == +=== Changes since MediaWiki 1.20.1 === * (bug 42638) Fix API action=options&reset=1 & unit tests. * (bug 42370) Fixed backport of 60cc060 to use mDoneWrites — caused * (bug 42592) User rights, preferences and other things are not saving in 1.20.1. == MediaWiki 1.20.1 == This is a security release of the MediaWiki 1.20 branch -Changes since 1.20 +=== Changes since 1.20.0 === * (bug 42202) Validate options to prevent html injection * (bug 40995) Prevent session fixation in Special:UserLogin (CVE-2012-5391) * (bug 41400) Prevent linker regex from exceeding PCRE backtrack limit @@ -3518,9 +3515,7 @@ Changes since 1.20 * (bug 40632) Remove CleanupPresentationalAttributes feature * [Database] Fixed case where trx idle callbacks might be lost. - - -== MediaWiki 1.20 == +== MediaWiki 1.20.0 == === PHP 5.3 now required === Since 1.20, the lowest supported version of PHP is now 5.3.2. Please @@ -3887,7 +3882,7 @@ changes to languages because of Bugzilla reports. == MediaWiki 1.19.21 == This is a maintenance release of the MediaWiki 1.19 branch. -=== Changes since 1.19.20=== +=== Changes since 1.19.20 === * (bug 67440) Allow classes to be registered properly from installer. * (bug 47281) Fixed a dumpBackup.php error with --uploads --include-filesoptions: Unable to find the wrapper "mwstore". * System administrators are encouraged to upgrade to this release or 1.22+ and produce a full data dump. https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Backing_up_a_wiki * (bug 63049) Removed anonymous functions from ApiFormatBase, added in1.19.13 as part of the fix for bug 61362, for PHP 5.2 compatibility. @@ -3895,73 +3890,73 @@ This is a maintenance release of the MediaWiki 1.19 branch. == MediaWiki 1.19.20 == This is a security release of the MediaWiki 1.19 branch. -=== Changes since 1.19.19=== +=== Changes since 1.19.19 === * (bug 70672) SECURITY: OutputPage: Remove separation of css and js module allowance. == MediaWiki 1.19.19 == This is a security release of the MediaWiki 1.19 branch. -=== Changes since 1.19.18=== +=== Changes since 1.19.18 === * (bug 69008) SECURITY: Enhance CSS filtering in SVG files. Filter + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png new file mode 100644 index 0000000000..8b0920f2a3 Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg new file mode 100644 index 0000000000..028c64c3da --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png new file mode 100644 index 0000000000..8dd4d77b3b Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg new file mode 100644 index 0000000000..a0f43ab117 --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png new file mode 100644 index 0000000000..709673fc3c Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg new file mode 100644 index 0000000000..6a9c683b38 --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png deleted file mode 100644 index ad816df8ad..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg deleted file mode 100644 index abc618e844..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png new file mode 100644 index 0000000000..36aaf52459 Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg new file mode 100644 index 0000000000..c67db52309 --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png new file mode 100644 index 0000000000..ef61b8b9f2 Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg new file mode 100644 index 0000000000..4bf074d9d6 --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png new file mode 100644 index 0000000000..aad12ac482 Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg new file mode 100644 index 0000000000..204f565394 --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png new file mode 100644 index 0000000000..8cd92821cb Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg new file mode 100644 index 0000000000..9b1ac394eb --- /dev/null +++ b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png deleted file mode 100644 index b4f0875ed2..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg deleted file mode 100644 index 956aba1f05..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png deleted file mode 100644 index 066e17fc24..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg deleted file mode 100644 index 0a4e04e84c..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png deleted file mode 100644 index 18ceb35943..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg deleted file mode 100644 index be25d43699..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png deleted file mode 100644 index cdcd15849e..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg deleted file mode 100644 index 431c5b8b16..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png deleted file mode 100644 index dc9b0e6366..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg deleted file mode 100644 index a41d178f5b..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png deleted file mode 100644 index fde9f52b7a..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg deleted file mode 100644 index 30915b7464..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png deleted file mode 100644 index 1025461375..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg deleted file mode 100644 index 8954a21a3e..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png deleted file mode 100644 index 09ab6312ed..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg deleted file mode 100644 index de634a8984..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png deleted file mode 100644 index 38066d69ef..0000000000 Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png and /dev/null differ diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg deleted file mode 100644 index 44ba9719c4..0000000000 --- a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/resources/src/jquery.tipsy/jquery.tipsy.css b/resources/src/jquery.tipsy/jquery.tipsy.css index 6471516ddc..33d9a003eb 100644 --- a/resources/src/jquery.tipsy/jquery.tipsy.css +++ b/resources/src/jquery.tipsy/jquery.tipsy.css @@ -28,46 +28,46 @@ height: 6px; } /* @noflip */ .tipsy-n .tipsy-arrow { - top: 0px; + top: 0; left: 50%; margin-left: -5px; } /* @noflip */ .tipsy-nw .tipsy-arrow { - top: 1px; + top: 0; left: 10px; } /* @noflip */ .tipsy-ne .tipsy-arrow { - top: 1px; + top: 0; right: 10px; } /* @noflip */ .tipsy-s .tipsy-arrow { - bottom: 0px; + bottom: 0; left: 50%; margin-left: -5px; background-position: bottom left; } /* @noflip */ .tipsy-sw .tipsy-arrow { - bottom: 0px; + bottom: 0; left: 10px; background-position: bottom left; } /* @noflip */ .tipsy-se .tipsy-arrow { - bottom: 0px; + bottom: 0; right: 10px; background-position: bottom left; } /* @noflip */ .tipsy-e .tipsy-arrow { top: 50%; margin-top: -5px; - right: 1px; - width: 5px; + right: 0; + width: 6px; height: 11px; background-position: top right; } /* @noflip */ .tipsy-w .tipsy-arrow { top: 50%; margin-top: -5px; - left: 0px; + left: 0; width: 6px; height: 11px; } diff --git a/resources/src/jquery.tipsy/jquery.tipsy.js b/resources/src/jquery.tipsy/jquery.tipsy.js index 29b7490f7f..2c6a5887e0 100644 --- a/resources/src/jquery.tipsy/jquery.tipsy.js +++ b/resources/src/jquery.tipsy/jquery.tipsy.js @@ -109,7 +109,6 @@ } }, - fixTitle: function() { var $e = this.$element; if ($e.attr('title') || typeof($e.attr('original-title')) != 'string') { diff --git a/resources/src/jquery/jquery.accessKeyLabel.js b/resources/src/jquery/jquery.accessKeyLabel.js index afa720117a..519e39bc8e 100644 --- a/resources/src/jquery/jquery.accessKeyLabel.js +++ b/resources/src/jquery/jquery.accessKeyLabel.js @@ -5,8 +5,8 @@ */ ( function ( $, mw ) { -// Cached access key prefix for used browser -var cachedAccessKeyPrefix, +// Cached access key modifiers for used browser +var cachedAccessKeyModifiers, // Whether to use 'test-' instead of correct prefix (used for testing) useTestPrefix = false, @@ -16,37 +16,39 @@ var cachedAccessKeyPrefix, labelable = 'button, input, textarea, keygen, meter, output, progress, select'; /** - * Get the prefix for the access key for browsers that don't support accessKeyLabel. + * Find the modifier keys that need to be pressed together with the accesskey to trigger the input. * + * The result is dependant on the ua paramater or the current platform. * For browsers that support accessKeyLabel, #getAccessKeyLabel never calls here. + * Valid key values that are returned can be: ctrl, alt, option, shift, esc * * @private * @param {Object} [ua] An object with a 'userAgent' and 'platform' property. - * @return {string} Access key prefix + * @return {Array} Array with 0 or more of the string values: ctrl, option, alt, shift, esc */ -function getAccessKeyPrefix( ua ) { +function getAccessKeyModifiers( ua ) { // use cached prefix if possible - if ( !ua && cachedAccessKeyPrefix ) { - return cachedAccessKeyPrefix; + if ( !ua && cachedAccessKeyModifiers ) { + return cachedAccessKeyModifiers; } var profile = $.client.profile( ua ), - accessKeyPrefix = 'alt-'; + accessKeyModifiers = [ 'alt' ]; // Classic Opera on any platform if ( profile.name === 'opera' && profile.versionNumber < 15 ) { - accessKeyPrefix = 'shift-esc-'; + accessKeyModifiers = [ 'shift', 'esc' ]; // Chrome and modern Opera on any platform } else if ( profile.name === 'chrome' || profile.name === 'opera' ) { - accessKeyPrefix = ( + accessKeyModifiers = ( profile.platform === 'mac' // Chrome on Mac - ? 'ctrl-option-' + ? [ 'ctrl', 'option' ] // Chrome on Windows or Linux // (both alt- and alt-shift work, but alt with E, D, F etc does not // work since they are browser shortcuts) - : 'alt-shift-' + : [ 'alt', 'shift' ] ); // Non-Windows Safari with webkit_version > 526 @@ -54,7 +56,7 @@ function getAccessKeyPrefix( ua ) { && profile.name === 'safari' && profile.layoutVersion > 526 ) { - accessKeyPrefix = 'ctrl-alt-'; + accessKeyModifiers = [ 'ctrl', 'alt' ]; // Safari/Konqueror on any platform, or any browser on Mac // (but not Safari on Windows) @@ -63,27 +65,27 @@ function getAccessKeyPrefix( ua ) { || profile.platform === 'mac' || profile.name === 'konqueror' ) ) { - accessKeyPrefix = 'ctrl-'; + accessKeyModifiers = [ 'ctrl' ]; // Firefox/Iceweasel 2.x and later } else if ( ( profile.name === 'firefox' || profile.name === 'iceweasel' ) && profile.versionBase > '1' ) { - accessKeyPrefix = 'alt-shift-'; + accessKeyModifiers = [ 'alt', 'shift' ]; } - // cache prefix + // cache modifiers if ( !ua ) { - cachedAccessKeyPrefix = accessKeyPrefix; + cachedAccessKeyModifiers = accessKeyModifiers; } - return accessKeyPrefix; + return accessKeyModifiers; } /** * Get the access key label for an element. * * Will use native accessKeyLabel if available (currently only in Firefox 8+), - * falls back to #getAccessKeyPrefix. + * falls back to #getAccessKeyModifiers. * * @private * @param {HTMLElement} element Element to get the label for @@ -99,7 +101,7 @@ function getAccessKeyLabel( element ) { if ( !useTestPrefix && element.accessKeyLabel ) { return element.accessKeyLabel; } - return ( useTestPrefix ? 'test-' : getAccessKeyPrefix() ) + element.accessKey; + return ( useTestPrefix ? 'test' : getAccessKeyModifiers().join( '-' ) ) + '-' + element.accessKey; } /** @@ -175,12 +177,30 @@ $.fn.updateTooltipAccessKeys = function () { }; /** - * Exposed for testing. + * getAccessKeyModifiers + * + * @method updateTooltipAccessKeys_getAccessKeyModifiers + * @inheritdoc #getAccessKeyModifiers + */ +$.fn.updateTooltipAccessKeys.getAccessKeyModifiers = getAccessKeyModifiers; + +/** + * getAccessKeyLabel + * + * @method updateTooltipAccessKeys_getAccessKeyLabel + * @inheritdoc #getAccessKeyLabel + */ +$.fn.updateTooltipAccessKeys.getAccessKeyLabel = getAccessKeyLabel; + +/** + * getAccessKeyPrefix * * @method updateTooltipAccessKeys_getAccessKeyPrefix - * @inheritdoc #getAccessKeyPrefix + * @deprecated 1.27 Use #getAccessKeyModifiers */ -$.fn.updateTooltipAccessKeys.getAccessKeyPrefix = getAccessKeyPrefix; +$.fn.updateTooltipAccessKeys.getAccessKeyPrefix = function ( ua ) { + return getAccessKeyModifiers( ua ).join( '-' ) + '-'; +}; /** * Switch test mode on and off. diff --git a/resources/src/jquery/jquery.farbtastic.css b/resources/src/jquery/jquery.farbtastic.css index 1c6428f8a5..baba3480fb 100644 --- a/resources/src/jquery/jquery.farbtastic.css +++ b/resources/src/jquery/jquery.farbtastic.css @@ -51,4 +51,3 @@ /* @embed */ background: url(images/marker.png) no-repeat; } - diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.preview.js b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js index 6de537abcb..1d4d0e954c 100644 --- a/resources/src/mediawiki.action/mediawiki.action.edit.preview.js +++ b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js @@ -41,6 +41,7 @@ copySelectors = [ // Main + '.mw-indicators', '#firstHeading', '#wikiPreview', '#wikiDiff', @@ -117,7 +118,7 @@ $wikiDiff.hide(); $.extend( postData, { - prop: 'text|displaytitle|modules|jsconfigvars|categorieshtml|templates|langlinks|limitreporthtml', + prop: 'text|indicators|displaytitle|modules|jsconfigvars|categorieshtml|templates|langlinks|limitreporthtml', text: $textbox.textSelection( 'getContents' ), pst: true, preview: true, @@ -142,6 +143,22 @@ response.parse.modulestyles ) ); } + + newList = []; + $.each( response.parse.indicators, function ( i, indicator ) { + newList.push( + $( '
' ) + .addClass( 'mw-indicator' ) + .attr( 'id', mw.util.escapeId( 'mw-indicator-' + indicator.name ) ) + .html( indicator[ '*' ] ) + .get( 0 ), + // Add a whitespace between the
s because + // they get displayed with display: inline-block + document.createTextNode( '\n' ) + ); + } ); + $( '.mw-indicators' ).empty().append( newList ); + if ( response.parse.displaytitle ) { $displaytitle = $( $.parseHTML( response.parse.displaytitle ) ); $( '#firstHeading' ).msg( @@ -157,7 +174,9 @@ ); } if ( response.parse.categorieshtml ) { - $( '#catlinks' ).replaceWith( response.parse.categorieshtml[ '*' ] ); + $content = $( $.parseHTML( response.parse.categorieshtml[ '*' ] ) ); + mw.hook( 'wikipage.categories' ).fire( $content ); + $( '.catlinks[data-mw="interface"]' ).replaceWith( $content ); } if ( response.parse.templates ) { newList = []; diff --git a/resources/src/mediawiki.action/mediawiki.action.view.filepage.css b/resources/src/mediawiki.action/mediawiki.action.view.filepage.css index bfc201afc7..5112728b16 100644 --- a/resources/src/mediawiki.action/mediawiki.action.view.filepage.css +++ b/resources/src/mediawiki.action/mediawiki.action.view.filepage.css @@ -68,4 +68,3 @@ ul#filetoc { #shared-image-conflict { font-style: italic; } - diff --git a/resources/src/mediawiki.legacy/commonPrint.css b/resources/src/mediawiki.legacy/commonPrint.css index 453d9289e2..4803a0a73e 100644 --- a/resources/src/mediawiki.legacy/commonPrint.css +++ b/resources/src/mediawiki.legacy/commonPrint.css @@ -117,22 +117,16 @@ pre, .mw-code { border: 1px solid #aaaaaa; background-color: #f9f9f9; padding: 5px; - display: inline-block; display: table; - /* IE7 and earlier */ - zoom: 1; - *display: inline; } /* Separate columns for tocnumber and toctext */ -/* Ignored by IE7 and lower */ .tocnumber, .toctext { display: table-cell; } /* Space between the columns for tocnumber and toctext */ -/* Ignored by IE7 and lower */ .tocnumber:after { content: ""; padding-right: 0.5em; @@ -326,7 +320,6 @@ a.sortheader { list-style: none; list-style-type: none; list-style-image: none; - vertical-align: middle !ie; } .catlinks li { @@ -335,8 +328,6 @@ a.sortheader { padding: 0 .4em; border-left: 1px solid #AAA; margin: 0.1em 0; - zoom: 1; - display: inline !ie; } .catlinks li:first-child { diff --git a/resources/src/mediawiki.less/mediawiki.mixins.less b/resources/src/mediawiki.less/mediawiki.mixins.less index 06c18e6d6c..f6c407a3db 100644 --- a/resources/src/mediawiki.less/mediawiki.mixins.less +++ b/resources/src/mediawiki.less/mediawiki.mixins.less @@ -19,7 +19,6 @@ background-size: @width @height; } - .vertical-gradient(@startColor: gray, @endColor: white, @startPos: 0, @endPos: 100%) { background-color: @endColor; background-image: -moz-linear-gradient( top, @startColor @startPos, @endColor @endPos ); // Firefox 3.6+ @@ -78,15 +77,15 @@ } .column-width(@value) { - -webkit-column-width: @value; // Chrome Any, Safari 3+, Opera 11.1+ + -webkit-column-width: @value; // Chrome Any, Safari 3+, Opera 15+ -moz-column-width: @value; // Firefox 1.5+ - column-width: @value; // IE 10+ + column-width: @value; // IE 10+, Opera 11.1-12.1 } .column-break-inside-avoid() { - -webkit-column-break-inside: avoid; // Chrome Any, Safari 3+, Opera 11.1+ + -webkit-column-break-inside: avoid; // Chrome Any, Safari 3+, Opera 15+ page-break-inside: avoid; // Firefox 1.5+ - break-inside: avoid-column; // IE 10+ + break-inside: avoid-column; // IE 10+, Opera 11.1-12.1 } .flex-display(@display: flex) { diff --git a/resources/src/mediawiki.less/mediawiki.mixins.rotation.less b/resources/src/mediawiki.less/mediawiki.mixins.rotation.less index e28b333f97..85b6bd41ff 100644 --- a/resources/src/mediawiki.less/mediawiki.mixins.rotation.less +++ b/resources/src/mediawiki.less/mediawiki.mixins.rotation.less @@ -20,10 +20,6 @@ .rotate-frames; } -@-o-keyframes rotate { - .rotate-frames; -} - @keyframes rotate { .rotate-frames; } diff --git a/resources/src/mediawiki.less/mediawiki.ui/variables.less b/resources/src/mediawiki.less/mediawiki.ui/variables.less index 4b6bb48b36..507109ae2c 100644 --- a/resources/src/mediawiki.less/mediawiki.ui/variables.less +++ b/resources/src/mediawiki.less/mediawiki.ui/variables.less @@ -11,32 +11,32 @@ @colorGray7: #777; @colorGray8: #888; @colorGray9: #999; -@colorGray10: #AAA; -@colorGray11: #BBB; -@colorGray12: #CCC; -@colorGray13: #DDD; -@colorGray14: #EEE; -@colorGray15: #F9F9F9; // lightest +@colorGray10: #aaa; +@colorGray11: #bbb; +@colorGray12: #ccc; +@colorGray13: #ddd; +@colorGray14: #eee; +@colorGray15: #f9f9f9; // lightest // Semantic background colors // Blue; for contextual use of a continuing action @colorProgressive: #347bff; -@colorProgressiveHighlight: #2962CC; -@colorProgressiveActive: #2962CC; +@colorProgressiveHighlight: #2962cc; +@colorProgressiveActive: #2962cc; // Green; for contextual use of a positive finalizing action @colorConstructive: #00af89; -@colorConstructiveHighlight: #008C6D; -@colorConstructiveActive: #008C6D; +@colorConstructiveHighlight: #008c6d; +@colorConstructiveActive: #008c6d; // Orange; for contextual use of returning to a past action -@colorRegressive: #FF5D00; +@colorRegressive: #ff5d00; // Red; for contextual use of a negative action of high severity @colorDestructive: #d11d13; -@colorDestructiveHighlight: #A7170F; -@colorDestructiveActive: #A7170F; +@colorDestructiveHighlight: #a7170f; +@colorDestructiveActive: #a7170f; // Orange; for contextual use of a potentially negative action of medium severity -@colorMediumSevere: #FF5D00; +@colorMediumSevere: #ff5d00; // Yellow; for contextual use of a potentially negative action of low severity -@colorLowSevere: #FFB50D; +@colorLowSevere: #ffb50d; // Used in mixins to darken contextual colors by the same amount (eg. focus) @colorDarkenPercentage: 13.5%; @@ -50,7 +50,7 @@ @colorButtonTextHighlight: @colorGray7; @colorButtonTextActive: @colorGray7; @colorDisabledText: @colorGray12; -@colorErrorText: #CC0000; +@colorErrorText: #c00; // UI colors @colorFieldBorder: @colorGray12; @@ -69,7 +69,10 @@ // Global border radius to be used to buttons and inputs @borderRadius: 2px; - // Icon related variables @iconSize: 1.5em; @iconGutterWidth: 1em; + +// Form input sizes +@checkboxSize: 2em; +@radioSize: 2em; diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js index 113fb00cd5..c51a07a10c 100644 --- a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js @@ -17,7 +17,9 @@ * Post a message (with subject and body) to a talk page. * * @abstract - * @param {string} subject Subject/topic title; plaintext only (no wikitext or HTML) + * @param {string} subject Subject/topic title. The amount of wikitext supported is + * implementation-specific. It is recommended to only use basic wikilink syntax for + * maximum compatibility. * @param {string} body Body, as wikitext. Signature code will automatically be added * by MessagePosters that require one, unless the message already contains the string * ~~~. diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js index 69655a6c8f..68fb2aae0e 100644 --- a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js @@ -1,52 +1,54 @@ -/*global OO*/ +/*global OO */ ( function ( mw, $ ) { /** - * This is a factory for MessagePoster objects, which allows a pluggable to way to script leaving a - * talk page message. + * Factory for MessagePoster objects. This provides a pluggable to way to script the action + * of adding a message to someone's talk page. * * @class mw.messagePoster.factory * @singleton */ - function MwMessagePosterFactory() { + function MessagePosterFactory() { this.contentModelToClass = {}; } - OO.initClass( MwMessagePosterFactory ); + OO.initClass( MessagePosterFactory ); // Note: This registration scheme is currently not compatible with LQT, since that doesn't - // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext + // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext // MessagePoster. /** - * Registers a MessagePoster subclass for a given content model. + * Register a MessagePoster subclass for a given content model. * * @param {string} contentModel Content model of pages this MessagePoster can post to - * @param {Function} messagePosterConstructor Constructor for MessagePoster + * @param {Function} constructor Constructor of a MessagePoster subclass */ - MwMessagePosterFactory.prototype.register = function ( contentModel, messagePosterConstructor ) { + MessagePosterFactory.prototype.register = function ( contentModel, constructor ) { if ( this.contentModelToClass[ contentModel ] !== undefined ) { - throw new Error( 'The content model \'' + contentModel + '\' is already registered.' ); + throw new Error( 'Content model "' + contentModel + '" is already registered' ); } - this.contentModelToClass[ contentModel ] = messagePosterConstructor; + this.contentModelToClass[ contentModel ] = constructor; }; /** - * Unregisters a given content model - * This is exposed for testing and should not normally be needed. + * Unregister a given content model. + * This is exposed for testing and should not normally be used. * * @param {string} contentModel Content model to unregister */ - MwMessagePosterFactory.prototype.unregister = function ( contentModel ) { + MessagePosterFactory.prototype.unregister = function ( contentModel ) { delete this.contentModelToClass[ contentModel ]; }; /** - * Creates a MessagePoster, given a title. A promise for this is returned. - * This works by determining the content model, then loading the corresponding - * module (which will register the MessagePoster class), and finally constructing it. + * Create a MessagePoster for given a title. * - * This does not require the message and should be called as soon as possible, so it does the - * API and ResourceLoader requests in the background. + * A promise for this is returned. It works by determining the content model, then loading + * the corresponding module (which registers the MessagePoster class), and finally constructing + * an object for the given title. + * + * This does not require the message and should be called as soon as possible, so that the + * API and ResourceLoader requests run in the background. * * @param {mw.Title} title Title that will be posted to * @param {string} [apiUrl] api.php URL if the title is on another wiki @@ -57,61 +59,54 @@ * - error Error explanation * - details Further error details */ - MwMessagePosterFactory.prototype.create = function ( title, apiUrl ) { - var pageId, page, contentModel, moduleName, api, - factory = this; - - if ( apiUrl ) { - api = new mw.ForeignApi( apiUrl ); - } else { - api = new mw.Api(); - } + MessagePosterFactory.prototype.create = function ( title, apiUrl ) { + var factory = this, + api = apiUrl ? new mw.ForeignApi( apiUrl ) : new mw.Api(); return api.get( { action: 'query', prop: 'info', indexpageids: true, titles: title.getPrefixedDb() - } ).then( function ( result ) { - if ( result.query.pageids && result.query.pageids.length > 0 ) { - pageId = result.query.pageids[ 0 ]; - page = result.query.pages[ pageId ]; - - contentModel = page.contentmodel; - moduleName = 'mediawiki.messagePoster.' + contentModel; - return mw.loader.using( moduleName ).then( function () { - return factory.createForContentModel( - contentModel, - title, - api - ); - }, function () { - return $.Deferred().reject( 'failed-to-load-module', 'Failed to load the \'' + moduleName + '\' module' ); - } ); - } else { + } ).then( function ( data ) { + var pageId, page, contentModel, moduleName; + if ( !data.query.pageids[ 0 ] ) { return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' ); } - }, function ( errorCode, details ) { - return $.Deferred().reject( 'content-model-query-failed', errorCode, details ); - } ).promise(); + pageId = data.query.pageids[ 0 ]; + page = data.query.pages[ pageId ]; + + contentModel = page.contentmodel; + moduleName = 'mediawiki.messagePoster.' + contentModel; + return mw.loader.using( moduleName ).then( function () { + return factory.createForContentModel( + contentModel, + title, + api + ); + }, function () { + return $.Deferred().reject( 'failed-to-load-module', 'Failed to load "' + moduleName + '"' ); + } ); + }, function ( error, details ) { + return $.Deferred().reject( 'content-model-query-failed', error, details ); + } ); }; /** * Creates a MessagePoster instance, given a title and content model * * @private - * * @param {string} contentModel Content model of title * @param {mw.Title} title Title being posted to * @param {mw.Api} api mw.Api instance that the instance should use * @return {mw.messagePoster.MessagePoster} * */ - MwMessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) { + MessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) { return new this.contentModelToClass[ contentModel ]( title, api ); }; mw.messagePoster = { - factory: new MwMessagePosterFactory() + factory: new MessagePosterFactory() }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.skinning/content.parsoid.less b/resources/src/mediawiki.skinning/content.parsoid.less index a6515d2e7f..66b3fb2942 100644 --- a/resources/src/mediawiki.skinning/content.parsoid.less +++ b/resources/src/mediawiki.skinning/content.parsoid.less @@ -100,6 +100,11 @@ figure[typeof*='mw:Image'] { /* taken from .thumbcaption, plus .thumbinner */ padding: 1px 5px 5px; background-color: #f9f9f9; + + table { + /* reset caption side for tables inside figcaptions */ + caption-side: top; + } } } diff --git a/resources/src/mediawiki.skinning/elements.css b/resources/src/mediawiki.skinning/elements.css index d706d26143..787208559f 100644 --- a/resources/src/mediawiki.skinning/elements.css +++ b/resources/src/mediawiki.skinning/elements.css @@ -25,6 +25,14 @@ a:hover, a:focus { text-decoration: underline; } +a:lang(ar), +a:lang(kk-arab), +a:lang(mzn), +a:lang(ps), +a:lang(ur) { + text-decoration: none; +} + a.stub { color: #772233; } diff --git a/resources/src/mediawiki.special/mediawiki.special.blocklist.css b/resources/src/mediawiki.special/mediawiki.special.blocklist.css new file mode 100644 index 0000000000..b7513b60a5 --- /dev/null +++ b/resources/src/mediawiki.special/mediawiki.special.blocklist.css @@ -0,0 +1,4 @@ +.mw-htmlform-flatlist-item { + /* FIXME: There should be an option in OOUI to do that */ + display: inline-block !important; +} \ No newline at end of file diff --git a/resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less b/resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less new file mode 100644 index 0000000000..45d0485bcf --- /dev/null +++ b/resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less @@ -0,0 +1,19 @@ +@import "mediawiki.mixins"; + +.mw-special-ComparePages .mw-htmlform-ooui-wrapper { + width: 100%; +} + +.mw-special-ComparePages .oo-ui-layout.oo-ui-panelLayout.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed { + float: left; + width: 49%; + .box-sizing( border-box ); +} + +.mw-special-ComparePages .oo-ui-layout.oo-ui-panelLayout.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed:nth-of-type(2) { + margin-left: 2%; +} + +.mw-special-ComparePages .mw-htmlform-submit-buttons { + clear: both; +} diff --git a/resources/src/mediawiki.special/mediawiki.special.import.js b/resources/src/mediawiki.special/mediawiki.special.import.js index 5622b32a38..3357461f2a 100644 --- a/resources/src/mediawiki.special/mediawiki.special.import.js +++ b/resources/src/mediawiki.special/mediawiki.special.import.js @@ -2,7 +2,8 @@ * JavaScript for Special:Import */ ( function ( $ ) { - function updateImportSubprojectList( firstTime ) { + var subprojectListAlreadyShown; + function updateImportSubprojectList() { var $projectField = $( '#mw-import-table-interwiki #interwiki' ), $subprojectField = $projectField.parent().find( '#subproject' ), $selected = $projectField.find( ':selected' ), @@ -14,12 +15,13 @@ option = document.createElement( 'option' ); option.appendChild( document.createTextNode( el ) ); option.setAttribute( 'value', el ); - if ( oldValue === el && firstTime !== true ) { + if ( oldValue === el && subprojectListAlreadyShown === true ) { option.setAttribute( 'selected', 'selected' ); } return option; } ); $subprojectField.show().empty().append( options ); + subprojectListAlreadyShown = true; } else { $subprojectField.hide(); } @@ -29,7 +31,7 @@ var $projectField = $( '#mw-import-table-interwiki #interwiki' ); if ( $projectField.length ) { $projectField.change( updateImportSubprojectList ); - updateImportSubprojectList( true ); + updateImportSubprojectList(); } } ); }( jQuery ) ); diff --git a/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js b/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js deleted file mode 100644 index fb74e4ec2e..0000000000 --- a/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js +++ /dev/null @@ -1,37 +0,0 @@ -/*! - * JavaScript for Special:JavaScriptTest - */ -( function ( mw, $ ) { - $( function () { - - // Create useskin dropdown menu and reload onchange to the selected skin - // (only if a framework was found, not on error pages). - $( '#mw-javascripttest-summary' ).append( function () { - - var $html = $( '

' ), - select = ' further - $.each( mw.config.get( 'wgAvailableSkins' ), function ( id ) { - select += ''; - } ); - select += ''; - - // Bind onchange event handler and append to form - $html.append( - $( select ).change( function () { - var url = new mw.Uri(); - location.href = url.extend( { useskin: $( this ).val() } ); - } ) - ); - - return $html; - } ); - } ); - -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.js b/resources/src/mediawiki.special/mediawiki.special.preferences.js index f90f85914f..29322f423e 100644 --- a/resources/src/mediawiki.special/mediawiki.special.preferences.js +++ b/resources/src/mediawiki.special/mediawiki.special.preferences.js @@ -94,11 +94,6 @@ notif = null; } } ); - - // Remove now-unnecessary success=1 querystring to prevent reappearance of notification on reload - if ( history.replaceState ) { - history.replaceState( {}, document.title, location.href.replace( /&?success=1/, '' ) ); - } } } @@ -215,19 +210,27 @@ var minuteDiff, localTime, type = $tzSelect.val(); - if ( type === 'guess' ) { - // Get browser timezone & fill it in - minuteDiff = -( new Date().getTimezoneOffset() ); - $tzTextbox.val( minutesToHours( minuteDiff ) ); - $tzSelect.val( 'other' ); - $tzTextbox.prop( 'disabled', false ); - } else if ( type === 'other' ) { + if ( type === 'other' ) { + // User specified time zone manually in // Grab data from the textbox, parse it. minuteDiff = hoursToMinutes( $tzTextbox.val() ); } else { - // Grab data from the $tzSelect value - minuteDiff = parseInt( type.split( '|' )[ 1 ], 10 ) || 0; - $tzTextbox.val( minutesToHours( minuteDiff ) ); + // Time zone not manually specified by user + if ( type === 'guess' ) { + // Get browser timezone & fill it in + minuteDiff = -( new Date().getTimezoneOffset() ); + $tzTextbox.val( minutesToHours( minuteDiff ) ); + $tzSelect.val( 'other' ); + $tzTextbox.prop( 'disabled', false ); + } else { + // Grab data from the $tzSelect value + minuteDiff = parseInt( type.split( '|' )[ 1 ], 10 ) || 0; + $tzTextbox.val( minutesToHours( minuteDiff ) ); + } + + // Set defaultValue prop on the generated box so we don't trigger the + // unsaved preferences check + $tzTextbox.prop( 'defaultValue', $tzTextbox.val() ); } // Determine local time from server time and minutes difference, for display. @@ -261,14 +264,53 @@ } ); } + // Check if all of the form values are unchanged + function isPrefsChanged() { + var inputs = $( '#mw-prefs-form :input' ), + input, $input, inputType, + index, optIndex, + opt; + + for ( index = 0; index < inputs.length; index++ ) { + input = inputs[ index ]; + $input = $( input ); + + // Different types of inputs have different methods for accessing defaults + if ( $input.is( 'select' ) ) { // has defaultValue or defaultChecked + inputType = input.type; + if ( inputType === 'radio' || inputType === 'checkbox' ) { + if ( input.checked !== input.defaultChecked ) { + return true; + } + } else if ( input.value !== input.defaultValue ) { + return true; + } + } + } + + return false; + } + + // Disable the button to save preferences unless preferences have changed + // Check if preferences have been changed before JS has finished loading + if ( !isPrefsChanged() ) { + $( '#prefcontrol' ).prop( 'disabled', true ); + $( '#preferences > fieldset' ).one( 'change keydown mousedown', function () { + $( '#prefcontrol' ).prop( 'disabled', false ); + } ); + } + // Set up a message to notify users if they try to leave the page without // saving. - $( '#mw-prefs-form' ).data( 'origdata', $( '#mw-prefs-form' ).serialize() ); allowCloseWindow = mw.confirmCloseWindow( { - test: function () { - return $( '#mw-prefs-form' ).serialize() !== $( '#mw-prefs-form' ).data( 'origdata' ); - }, - + test: isPrefsChanged, message: mw.msg( 'prefswarning-warning', mw.msg( 'saveprefs' ) ), namespace: 'prefswarning' } ); diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.styles.css b/resources/src/mediawiki.special/mediawiki.special.preferences.styles.css index 4a797a67f7..e05d16344e 100644 --- a/resources/src/mediawiki.special/mediawiki.special.preferences.styles.css +++ b/resources/src/mediawiki.special/mediawiki.special.preferences.styles.css @@ -50,4 +50,3 @@ .client-js #preferences #mw-prefsection-personal { display: block; } - diff --git a/resources/src/mediawiki.ui/components/anchors.less b/resources/src/mediawiki.ui/components/anchors.less index f0fb7b95b3..5bb69b8a7a 100644 --- a/resources/src/mediawiki.ui/components/anchors.less +++ b/resources/src/mediawiki.ui/components/anchors.less @@ -11,7 +11,8 @@ color: lighten( @mainColor, @colorLightenPercentage ); } // Focus and active states - &:focus, &:active { + &:focus, + &:active { color: darken( @mainColor, @colorDarkenPercentage ); outline: none; // outline fix } @@ -74,7 +75,8 @@ Styleguide 6.2.1. &:hover { color: @mainColor; } - &:focus, &:active { + &:focus, + &:active { color: darken( @mainColor, @colorDarkenPercentage ); } } diff --git a/resources/src/mediawiki.ui/components/buttons.less b/resources/src/mediawiki.ui/components/buttons.less index 600b7711c1..4ffaeeee40 100644 --- a/resources/src/mediawiki.ui/components/buttons.less +++ b/resources/src/mediawiki.ui/components/buttons.less @@ -33,18 +33,18 @@ display: inline-block; padding: .5em 1em; margin: 0; - .box-sizing(border-box); + .box-sizing( border-box ); // Disable weird iOS styling -webkit-appearance: none; - // IE6/IE7 hack - // http://stackoverflow.com/a/5838575/365238 + // IE 6 & 7 hack + // https://stackoverflow.com/a/5838575/365238 *display: inline; zoom: 1; // Container styling - .button-colors(#FFF, #CCC, #777); + .button-colors( #fff, #ccc, #777 ); border-radius: @borderRadius; min-width: 4em; @@ -130,10 +130,10 @@ // Styleguide 2.1.1. &.mw-ui-progressive, &.mw-ui-primary { - .button-colors(@colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive); + .button-colors( @colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive ); &.mw-ui-quiet { - .button-colors-quiet(@colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive); + .button-colors-quiet( @colorProgressive, @colorProgressiveHighlight, @colorProgressiveActive ); } } @@ -153,10 +153,10 @@ // // Styleguide 2.1.2. &.mw-ui-constructive { - .button-colors(@colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive); + .button-colors( @colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive ); &.mw-ui-quiet { - .button-colors-quiet(@colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive); + .button-colors-quiet( @colorConstructive, @colorConstructiveHighlight, @colorConstructiveActive ); } } @@ -175,10 +175,10 @@ // // Styleguide 2.1.3. &.mw-ui-destructive { - .button-colors(@colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive); + .button-colors( @colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive ); &.mw-ui-quiet { - .button-colors-quiet(@colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive); + .button-colors-quiet( @colorDestructive, @colorDestructiveHighlight, @colorDestructiveActive ); } } @@ -213,9 +213,9 @@ // Styleguide 2.1.4. &.mw-ui-quiet { background: transparent; - border: none; + border: 0; text-shadow: none; - .button-colors-quiet(@colorButtonText, @colorButtonTextHighlight, @colorButtonTextActive); + .button-colors-quiet( @colorButtonText, @colorButtonTextHighlight, @colorButtonTextActive ); &:hover, &:focus { @@ -269,8 +269,8 @@ a.mw-ui-button { border-bottom-left-radius: @borderRadius; } - &:not(:first-child) { - border-left: none; + &:not( :first-child ) { + border-left: 0; } &:last-child{ diff --git a/resources/src/mediawiki.ui/components/checkbox.less b/resources/src/mediawiki.ui/components/checkbox.less index bd5dd4a297..d44e5d71df 100644 --- a/resources/src/mediawiki.ui/components/checkbox.less +++ b/resources/src/mediawiki.ui/components/checkbox.less @@ -5,8 +5,8 @@ // // Styling checkboxes in a way that works cross browser is a tricky problem to solve. // In MediaWiki UI put a checkbox and label inside a mw-ui-checkbox div. -// This renders in all browsers except IE6-8 which do not support the :checked selector; -// these are kept backwards-compatible using the :not(#noop) selector. +// This renders in all browsers except IE 6-8 which do not support the :checked selector; +// these are kept backwards-compatible using the `:not( #noop )` selector. // You should give the checkbox and label matching "id" and "for" attributes, respectively. // // Markup: @@ -33,13 +33,11 @@ vertical-align: middle; } -@checkboxSize: 2em; - // We use the not selector to cancel out styling on IE 8 and below -// We also disable this styling on javascript disabled devices. This fixes the issue with +// We also disable this styling on JavaScript disabled devices. This fixes the issue with // Opera Mini where checking/unchecking doesn't apply styling but potentially leaves other // more capable browsers with unstyled checkboxes. -.client-js .mw-ui-checkbox:not(#noop) { +.client-js .mw-ui-checkbox:not( #noop ) { // Position relatively so we can make use of absolute pseudo elements position: relative; display: table; @@ -62,8 +60,7 @@ height: @checkboxSize; // This is needed for Firefox mobile (See bug 71750 to workaround default Firefox stylesheet) max-width: none; - margin: 0; - margin-right: 0.4em; + margin: 0 0.4em 0 0; display: table-cell; & + label { @@ -73,25 +70,25 @@ // the pseudo before element of the label after the checkbox now looks like a checkbox & + label::before { content: ''; - cursor: pointer; - .box-sizing(border-box); + background-color: #fff; + .background-image-svg( 'images/checked.svg', 'images/checked.png' ); + background-position: center center; + background-origin: border-box; + background-repeat: no-repeat; + .background-size( @checkboxSize - 0.2em, @checkboxSize - 0.2em ); + background-size: 0 0; + .box-sizing( border-box ); position: absolute; + // align the checkbox to middle of the text + top: 50%; left: 0; - border-radius: @borderRadius; width: @checkboxSize; height: @checkboxSize; - line-height: @checkboxSize; - background-color: #fff; - border: 1px solid @colorGray7; - // align the checkbox to middle of the text - top: 50%; margin-top: -1em; - .background-image-svg('images/checked.svg', 'images/checked.png'); - .background-size( @checkboxSize - 0.2em, @checkboxSize - 0.2em ); - background-repeat: no-repeat; - background-position: center center; - background-origin: border-box; - background-size: 0 0; + border: 1px solid @colorGray7; + border-radius: @borderRadius; + line-height: @checkboxSize; + cursor: pointer; } // when the input is checked, style the label pseudo before element that followed as a checked checkbox @@ -122,7 +119,7 @@ // disabled and checked checkboxes have a white circle &:disabled:checked + label::before { - .background-image-svg('images/checked_disabled.svg', 'images/checked_disabled.png'); + .background-image-svg( 'images/checked_disabled.svg', 'images/checked_disabled.png' ); } } } diff --git a/resources/src/mediawiki.ui/components/forms.less b/resources/src/mediawiki.ui/components/forms.less index dc49e2023c..cc96a5c09c 100644 --- a/resources/src/mediawiki.ui/components/forms.less +++ b/resources/src/mediawiki.ui/components/forms.less @@ -36,7 +36,7 @@ // // Styleguide 5.1. .mw-ui-vform { - .box-sizing(border-box); + .box-sizing( border-box ); width: @defaultFormWidth; @@ -44,7 +44,7 @@ select, .mw-ui-button { display: block; - .box-sizing(border-box); + .box-sizing( border-box ); margin: 0; width: 100%; } @@ -52,13 +52,13 @@ // Give dropdown lists the same spacing as input fields for consistency. // Values taken from .agora-field-styling() in mixins/form.less select { - padding: 0.35em 0.5em 0.35em 0.5em; + padding: 0.35em 0.5em; vertical-align: middle; } > label { display: block; - .box-sizing(border-box); + .box-sizing( border-box ); .agora-label-styling(); width: auto; margin: 0 0 0.2em; @@ -68,11 +68,10 @@ // Override input styling just for checkboxes and radio inputs. input[type="radio"] { display: inline; - .box-sizing(content-box); + .box-sizing( content-box ); width: auto; } - // Styles for information boxes // // Regular HTMLForm uses .error class, some special pages like @@ -107,7 +106,7 @@ .errorbox, .warningbox, .successbox { - .box-sizing(border-box); + .box-sizing( border-box ); font-size: 0.9em; margin: 0 0 1em 0; padding: 0.5em; @@ -116,7 +115,7 @@ // Colours taken from those for .errorbox in shared.css .error { - color: #cc0000; + color: @colorErrorText; border: 1px solid #fac5c5; background-color: #fae3e3; text-shadow: 0 1px #fae3e3; diff --git a/resources/src/mediawiki.ui/components/icons.less b/resources/src/mediawiki.ui/components/icons.less index d9e8c4202e..9b9d32470d 100644 --- a/resources/src/mediawiki.ui/components/icons.less +++ b/resources/src/mediawiki.ui/components/icons.less @@ -2,10 +2,10 @@ @import "mediawiki.ui/variables"; // Mixins -.mixin-mw-ui-icon-bgimage(@iconSvg, @iconPng) { +.mixin-mw-ui-icon-bgimage( @iconSvg, @iconPng ) { &.mw-ui-icon { &:before { - .background-image-svg(@iconSvg, @iconPng); + .background-image-svg( @iconSvg, @iconPng ); } } } @@ -13,7 +13,7 @@ // Icons // // To use icons you must be using a browser that supports pseudo elements. -// This includes support for IE8. +// This includes support for IE 8. // http://caniuse.com/#feat=css-gencontent // // For elements that are intended to have both an icon and text, browsers that @@ -45,6 +45,7 @@ width: @width; min-width: @width; max-width: @width; + &:before { left: 0; right: 0; @@ -56,15 +57,14 @@ &.mw-ui-icon-before:before, &.mw-ui-icon-element:before { background-position: 50% 50%; - float: left; - display: block; background-repeat: no-repeat; background-size: 100% auto; + float: left; + display: block; min-height: @iconSize; content: ''; } - // Icons with text // // Markup: diff --git a/resources/src/mediawiki.ui/components/inputs.less b/resources/src/mediawiki.ui/components/inputs.less index 62f0e8345f..d0633aef68 100644 --- a/resources/src/mediawiki.ui/components/inputs.less +++ b/resources/src/mediawiki.ui/components/inputs.less @@ -29,16 +29,16 @@ .mw-ui-input { // turn off default input styling for input[type="search"] fields -webkit-appearance: none; - border: 1px solid @colorFieldBorder; - .box-sizing(border-box); - width: 100%; - padding: .3em .3em .3em .6em; + .box-sizing( border-box ); display: block; - vertical-align: middle; + width: 100%; + border: 1px solid @colorFieldBorder; border-radius: @borderRadius; + padding: 0.3em 0.3em 0.3em 0.6em; font-family: inherit; font-size: inherit; line-height: inherit; + vertical-align: middle; // Placeholder text styling must be set individually for each browser @winter &::-webkit-input-placeholder { // webkit diff --git a/resources/src/mediawiki.ui/components/radio.less b/resources/src/mediawiki.ui/components/radio.less index 52effd65b2..448390a726 100644 --- a/resources/src/mediawiki.ui/components/radio.less +++ b/resources/src/mediawiki.ui/components/radio.less @@ -5,8 +5,8 @@ // // Styling radios in a way that works cross browser is a tricky problem to solve. // In MediaWiki UI put a radio and label inside a mw-ui-radio div. -// This renders in all browsers except IE6-8 which do not support the :checked selector; -// these are kept backwards-compatible using the :not(#noop) selector. +// This renders in all browsers except IE 6-8 which do not support the :checked selector; +// these are kept backwards-compatible using the `:not( #noop )` selector. // You should give the radio and label matching "id" and "for" attributes, respectively. // // Markup: @@ -33,13 +33,11 @@ vertical-align: middle; } -@radioSize: 2em; - // We use the not selector to cancel out styling on IE 8 and below. -// We also disable this styling on javascript disabled devices. This fixes the issue with +// We also disable this styling on JavaScript disabled devices. This fixes the issue with // Opera Mini where checking/unchecking doesn't apply styling but potentially leaves other // more capable browsers with unstyled radio buttons. -.client-js .mw-ui-radio:not(#noop) { +.client-js .mw-ui-radio:not( #noop ) { // Position relatively so we can make use of absolute pseudo elements position: relative; line-height: @radioSize; @@ -64,21 +62,21 @@ // the pseudo before element of the label after the radio now looks like a radio & + label::before { content: ''; - cursor: pointer; - .box-sizing(border-box); + background-color: #fff; + .background-image-svg( 'images/radio_checked.svg', 'images/radio_checked.png' ); + background-origin: border-box; + background-position: center center; + background-repeat: no-repeat; + .background-size( @radioSize, @radioSize ); + background-size: 0 0; + .box-sizing( border-box ); position: absolute; left: 0; - border-radius: 100%; width: @radioSize; height: @radioSize; - background-color: #fff; border: 1px solid @colorGray7; - .background-image-svg('images/radio_checked.svg', 'images/radio_checked.png'); - .background-size( @radioSize, @radioSize ); - background-repeat: no-repeat; - background-position: center center; - background-origin: border-box; - background-size: 0 0; + border-radius: 100%; + cursor: pointer; } // when the input is checked, style the label pseudo before element that followed as a checked radio @@ -102,14 +100,14 @@ // disabled radios have a gray background &:disabled + label::before { - cursor: default; background-color: @colorGray14; border-color: @colorGray14; + cursor: default; } // disabled and checked radios have a white circle &:disabled:checked + label::before { - .background-image-svg('images/radio_disabled.svg', 'images/radio_disabled.png'); + .background-image-svg( 'images/radio_disabled.svg', 'images/radio_disabled.png' ); } } } diff --git a/resources/src/mediawiki.ui/components/text.less b/resources/src/mediawiki.ui/components/text.less index 500d42c4a4..cc27e9e357 100644 --- a/resources/src/mediawiki.ui/components/text.less +++ b/resources/src/mediawiki.ui/components/text.less @@ -26,7 +26,7 @@ Styleguide 6.1. */ .mw-ui-text { - // The selector order is like this on purpose; IE6 ignores the second selector, + // The selector order is like this on purpose; IE 6 ignores the second selector, // so we don't want to accidentally apply this color on all mw-ui-CONTEXT classes .mw-ui-progressive& { color: @colorProgressive; diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.js b/resources/src/mediawiki.widgets.datetime/CalendarWidget.js new file mode 100644 index 0000000000..31b1cd5b44 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/CalendarWidget.js @@ -0,0 +1,593 @@ +( function ( $, mw ) { + + /** + * CalendarWidget displays a calendar that can be used to select a date. It + * uses {@link mw.widgets.datetime.DateTimeFormatter DateTimeFormatter} to get the details of + * the calendar. + * + * This widget is mainly intended to be used as a popup from a + * {@link mw.widgets.datetime.DateTimeInputWidget DateTimeInputWidget}, but may also be used + * standalone. + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.TabIndexedElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for + * mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, or an mw.widgets.datetime.DateTimeFormatter + * instance to use. + * @cfg {OO.ui.Widget|null} [widget=null] Widget associated with the calendar. + * Specifying this configures the calendar to be used as a popup from the + * specified widget (e.g. absolute positioning, automatic hiding when clicked + * outside). + * @cfg {Date|null} [min=null] Minimum allowed date + * @cfg {Date|null} [max=null] Maximum allowed date + * @cfg {Date} [focusedDate] Initially focused date. + * @cfg {Date|Date[]|null} [selected=null] Selected date(s). + */ + mw.widgets.datetime.CalendarWidget = function MwWidgetsDatetimeCalendarWidget( config ) { + var $colgroup, $headTR, headings, i; + + // Configuration initialization + config = $.extend( { + min: null, + max: null, + focusedDate: new Date(), + selected: null, + formatter: {} + }, config ); + + // Parent constructor + mw.widgets.datetime.CalendarWidget[ 'super' ].call( this, config ); + + // Mixin constructors + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) ); + + // Properties + if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) { + this.min = config.min; + } else { + this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z + } + if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) { + this.max = config.max; + } else { + this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z + } + + if ( config.focusedDate instanceof Date ) { + this.focusedDate = config.focusedDate; + } else { + this.focusedDate = new Date(); + } + + this.selected = []; + + if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) { + this.formatter = config.formatter; + } else if ( $.isPlainObject( config.formatter ) ) { + this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter ); + } else { + throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' ); + } + + this.calendarData = null; + + this.widget = config.widget; + this.$widget = config.widget ? config.widget.$element : null; + this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this ); + + this.$head = $( '
' ); + this.$header = $( '' ); + this.$table = $( '' ); + this.cols = []; + this.colNullable = []; + this.headings = []; + this.$tableBody = $( '' ); + this.rows = []; + this.buttons = {}; + this.minWidth = 1; + this.daysPerWeek = 0; + + // Events + this.$element.on( { + keydown: this.onKeyDown.bind( this ) + } ); + this.formatter.connect( this, { + local: 'onLocalChange' + } ); + if ( this.$widget ) { + this.checkFocusHandler = this.checkFocus.bind( this ); + this.$element.on( { + focusout: this.onFocusOut.bind( this ) + } ); + this.$widget.on( { + focusout: this.onFocusOut.bind( this ) + } ); + } + + // Initialization + this.$head + .addClass( 'mw-widgets-datetime-calendarWidget-heading' ) + .append( + new OO.ui.ButtonWidget( { + icon: 'previous', + framed: false, + classes: [ 'mw-widgets-datetime-calendarWidget-previous' ], + tabIndex: -1 + } ).connect( this, { click: 'onPrevClick' } ).$element, + new OO.ui.ButtonWidget( { + icon: 'next', + framed: false, + classes: [ 'mw-widgets-datetime-calendarWidget-next' ], + tabIndex: -1 + } ).connect( this, { click: 'onNextClick' } ).$element, + this.$header + ); + $colgroup = $( '' ); + $headTR = $( '' ); + this.$table + .addClass( 'mw-widgets-datetime-calendarWidget-grid' ) + .append( $colgroup ) + .append( $( '' ).append( $headTR ) ) + .append( this.$tableBody ); + + headings = this.formatter.getCalendarHeadings(); + for ( i = 0; i < headings.length; i++ ) { + this.cols[ i ] = $( '' ); + this.headings[ i ] = $( '' ); + } else { + this.rows[ r ].children().detach(); + } + this.$tableBody.append( this.rows[ r ] ); + row = this.calendarData.rows[ r ]; + for ( c = 0; c < row.length; c++ ) { + day = row[ c ]; + if ( day === null ) { + k = 'empty-' + r + '-' + c; + if ( !this.buttons[ k ] ) { + this.buttons[ k ] = $( '
' ); + this.colNullable[ i ] = headings[ i ] === null; + if ( headings[ i ] !== null ) { + this.headings[ i ].text( headings[ i ] ); + this.minWidth = Math.max( this.minWidth, headings[ i ].length ); + this.daysPerWeek++; + } + $colgroup.append( this.cols[ i ] ); + $headTR.append( this.headings[ i ] ); + } + + this.setSelected( config.selected ); + this.$element + .addClass( 'mw-widgets-datetime-calendarWidget' ) + .append( this.$head, this.$table ); + + if ( this.widget ) { + this.$element.addClass( 'mw-widgets-datetime-calendarWidget-dependent' ); + + // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods + // that reference properties not initialized at that time of parent class construction + // TODO: Find a better way to handle post-constructor setup + this.visible = false; + this.$element.addClass( 'oo-ui-element-hidden' ); + } else { + this.updateUI(); + } + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.CalendarWidget, OO.ui.Widget ); + OO.mixinClass( mw.widgets.datetime.CalendarWidget, OO.ui.mixin.TabIndexedElement ); + + /* Events */ + + /** + * A `change` event is emitted when the selected dates change + * + * @event change + */ + + /** + * A `focusChange` event is emitted when the focused date changes + * + * @event focusChange + */ + + /** + * A `page` event is emitted when the current "month" changes + * + * @event page + */ + + /* Methods */ + + /** + * Return the current selected dates + * + * @return {Date[]} + */ + mw.widgets.datetime.CalendarWidget.prototype.getSelected = function () { + return this.selected; + }; + + /** + * Set the selected dates + * + * @param {Date|Date[]|null} dates + * @fires change + * @chainable + */ + mw.widgets.datetime.CalendarWidget.prototype.setSelected = function ( dates ) { + var i, changed = false; + + if ( dates instanceof Date ) { + dates = [ dates ]; + } else if ( Array.isArray( dates ) ) { + dates = $.grep( dates, function ( dt ) { return dt instanceof Date; } ); + dates.sort(); + } else { + dates = []; + } + + if ( this.selected.length !== dates.length ) { + changed = true; + } else { + for ( i = 0; i < dates.length; i++ ) { + if ( dates[ i ].getTime() !== this.selected[ i ].getTime() ) { + changed = true; + break; + } + } + } + + if ( changed ) { + this.selected = dates; + this.emit( 'change', dates ); + this.updateUI(); + } + + return this; + }; + + /** + * Return the currently-focused date + * + * @return {Date} + */ + mw.widgets.datetime.CalendarWidget.prototype.getFocusedDate = function () { + return this.focusedDate; + }; + + /** + * Set the currently-focused date + * + * @param {Date} date + * @fires page + * @chainable + */ + mw.widgets.datetime.CalendarWidget.prototype.setFocusedDate = function ( date ) { + var changePage = false, + updateUI = false; + + if ( this.focusedDate.getTime() === date.getTime() ) { + return this; + } + + if ( !this.formatter.sameCalendarGrid( this.focusedDate, date ) ) { + changePage = true; + updateUI = true; + } else if ( + !this.formatter.timePartIsEqual( this.focusedDate, date ) || + !this.formatter.datePartIsEqual( this.focusedDate, date ) + ) { + updateUI = true; + } + + this.focusedDate = date; + this.emit( 'focusChanged', this.focusedDate ); + if ( changePage ) { + this.emit( 'page', date ); + } + if ( updateUI ) { + this.updateUI(); + } + + return this; + }; + + /** + * Adjust a date + * + * @protected + * @param {Date} date Date to adjust + * @param {string} component Component: 'month', 'week', or 'day' + * @param {number} delta Integer, usually -1 or 1 + * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max + * @return {Date} + */ + mw.widgets.datetime.CalendarWidget.prototype.adjustDate = function ( date, component, delta ) { + var newDate, + data = this.calendarData; + + if ( !data ) { + return date; + } + + switch ( component ) { + case 'month': + newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' ); + break; + + case 'week': + if ( data.weekComponent === undefined ) { + newDate = this.formatter.adjustComponent( + date, data.dayComponent, delta * this.daysPerWeek, 'overflow' ); + } else { + newDate = this.formatter.adjustComponent( date, data.weekComponent, delta, 'overflow' ); + } + break; + + case 'day': + newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' ); + break; + + default: + throw new Error( 'Unknown component' ); + } + + while ( newDate < this.min ) { + newDate = this.formatter.adjustComponent( newDate, data.dayComponent, 1, 'overflow' ); + } + while ( newDate > this.max ) { + newDate = this.formatter.adjustComponent( newDate, data.dayComponent, -1, 'overflow' ); + } + + return newDate; + }; + + /** + * Update the user interface + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.updateUI = function () { + var r, c, row, day, k, $cell, + width = this.minWidth, + nullCols = [], + focusedDate = this.getFocusedDate(), + selected = this.getSelected(), + datePartIsEqual = this.formatter.datePartIsEqual.bind( this.formatter ), + isSelected = function ( dt ) { + return datePartIsEqual( this, dt ); + }; + + this.calendarData = this.formatter.getCalendarData( focusedDate ); + + this.$header.text( this.calendarData.header ); + + for ( c = 0; c < this.colNullable.length; c++ ) { + nullCols[ c ] = this.colNullable[ c ]; + if ( nullCols[ c ] ) { + for ( r = 0; r < this.calendarData.rows.length; r++ ) { + if ( this.calendarData.rows[ r ][ c ] ) { + nullCols[ c ] = false; + break; + } + } + } + } + + this.$tableBody.children().detach(); + for ( r = 0; r < this.calendarData.rows.length; r++ ) { + if ( !this.rows[ r ] ) { + this.rows[ r ] = $( '
' ); + } + $cell = this.buttons[ k ]; + $cell.toggleClass( 'oo-ui-element-hidden', nullCols[ c ] ); + } else { + k = ( day.extra ? day.extra : '' ) + day.display; + width = Math.max( width, day.display.length ); + if ( !this.buttons[ k ] ) { + this.buttons[ k ] = new OO.ui.ButtonWidget( { + $element: $( '' ), + classes: [ + 'mw-widgets-datetime-calendarWidget-cell', + day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : '' + ], + framed: true, + label: day.display, + tabIndex: -1 + } ); + this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } ); + } + this.buttons[ k ] + .setData( day.date ) + .setDisabled( day.date < this.min || day.date > this.max ); + $cell = this.buttons[ k ].$element; + $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-focused', + this.formatter.datePartIsEqual( focusedDate, day.date ) ); + $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-selected', + selected.some( isSelected, day.date ) ); + } + this.rows[ r ].append( $cell ); + } + } + + for ( c = 0; c < this.cols.length; c++ ) { + if ( nullCols[ c ] ) { + this.cols[ c ].width( 0 ); + } else { + this.cols[ c ].width( width + 'em' ); + } + this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] ); + this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] ); + } + }; + + /** + * Handles formatter 'local' flag changing + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () { + if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) { + this.emit( 'page', this.getFocusedDate() ); + } + + this.updateUI(); + }; + + /** + * Handles previous button click + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onPrevClick = function () { + this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles next button click + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onNextClick = function () { + this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles day button click + * + * @protected + * @param {OO.ui.ButtonWidget} $button + */ + mw.widgets.datetime.CalendarWidget.prototype.onDayClick = function ( $button ) { + this.setFocusedDate( $button.getData() ); + this.setSelected( [ $button.getData() ] ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles document mouse down events. + * + * @protected + * @param {jQuery.Event} e Mouse down event + */ + mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) { + if ( this.$widget && + !OO.ui.contains( this.$element[ 0 ], e.target, true ) && + !OO.ui.contains( this.$widget[ 0 ], e.target, true ) + ) { + this.toggle( false ); + } + }; + + /** + * Handles key presses. + * + * @protected + * @param {jQuery.Event} e Key down event + */ + mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) { + var focusedDate = this.getFocusedDate(); + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case OO.ui.Keys.ENTER: + case OO.ui.Keys.SPACE: + this.setSelected( [ focusedDate ] ); + return false; + + case OO.ui.Keys.LEFT: + this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) ); + return false; + + case OO.ui.Keys.RIGHT: + this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) ); + return false; + + case OO.ui.Keys.UP: + this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) ); + return false; + + case OO.ui.Keys.DOWN: + this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) ); + return false; + + case OO.ui.Keys.PAGEUP: + this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) ); + return false; + + case OO.ui.Keys.PAGEDOWN: + this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) ); + return false; + } + } + }; + + /** + * Handles focusout events in dependent mode + * + * @private + */ + mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () { + setTimeout( this.checkFocusHandler ); + }; + + /** + * When we or our widget lost focus, check if the calendar should be hidden. + * + * @private + */ + mw.widgets.datetime.CalendarWidget.prototype.checkFocus = function () { + var containers = [ this.$element[ 0 ], this.$widget[ 0 ] ], + activeElement = document.activeElement; + + if ( !activeElement || !OO.ui.contains( containers, activeElement, true ) ) { + this.toggle( false ); + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) { + var change; + + visible = ( visible === undefined ? !this.visible : !!visible ); + change = visible !== this.isVisible(); + + // Parent method + mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible ); + + if ( change ) { + if ( visible ) { + // Auto-hide + if ( this.$widget ) { + this.getElementDocument().addEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + this.updateUI(); + } else { + this.getElementDocument().removeEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + } + + return this; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.less b/resources/src/mediawiki.widgets.datetime/CalendarWidget.less new file mode 100644 index 0000000000..a7beb0df55 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/CalendarWidget.less @@ -0,0 +1,74 @@ +@import "mediawiki.widgets.datetime.definitions"; + +.mw-widgets-datetime-calendarWidget { + display: inline-block; + position: relative; + vertical-align: middle; + padding: .5em; + + &.mw-widgets-datetime-calendarWidget-dependent { + display: block; + position: absolute; + z-index: 4; + } + + &-grid { + table-layout: fixed; + + .mw-widgets-datetime-calendarWidget-cell { + display: table-cell; + white-space: nowrap; + } + } + + background-color: white; + border: 1px solid #ccc; + + &.mw-widgets-datetime-calendarWidget-dependent { + margin-top: -1px; + border-top: 1px solid white; + } + + &-heading { + text-align: center; + vertical-align: middle; + font-weight: bold; + white-space: nowrap; + + .mw-widgets-datetime-calendarWidget-previous { + float: left; + } + .mw-widgets-datetime-calendarWidget-next { + float: right; + } + } + + &-grid { + margin: 0 auto; + + .mw-widgets-datetime-calendarWidget-cell { + text-align: center; + + .oo-ui-buttonElement-button { + width: 100%; + border: 1px dotted rgba(255,255,255,0.0); + .oo-ui-box-sizing( border-box ); + } + + &.mw-widgets-datetime-calendarWidget-extra .oo-ui-buttonElement-button .oo-ui-labelElement-label { + color: #bbb; + } + + &.mw-widgets-datetime-calendarWidget-selected .oo-ui-buttonElement-button { + background-color: #def; + .oo-ui-labelElement-label { + color: #38f; + } + } + } + } + + &:focus &-grid &-cell&-focused .oo-ui-buttonElement-button { + border-color: rgba(0,0,0,0.3); + } +} diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js new file mode 100644 index 0000000000..1c542341d3 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js @@ -0,0 +1,623 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. + * + * @class + * @abstract + * @mixins OO.EventEmitter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [format='@default'] May be a key from the {@link #static-formats static formats}, + * or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec} + * and {@link #method-getFieldForTag getFieldForTag}. + * @cfg {boolean} [local=false] Whether dates are local time or UTC + * @cfg {string[]} [fullZones] Time zone indicators. Array of 2 strings, for + * UTC and local time. + * @cfg {string[]} [shortZones] Abbreviated time zone indicators. Array of 2 + * strings, for UTC and local time. + * @cfg {Date} [defaultDate] Default date, for filling unspecified components. + * Defaults to the current date and time (with 0 milliseconds). + */ + mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) { + var statick = this.constructor[ 'static' ]; + + statick.setupDefaults(); + + config = $.extend( { + format: '@default', + local: false, + fullZones: statick.fullZones, + shortZones: statick.shortZones + }, config ); + + // Mixin constructors + OO.EventEmitter.call( this ); + + // Properties + if ( statick.formats[ config.format ] ) { + this.format = statick.formats[ config.format ]; + } else { + this.format = config.format; + } + this.local = !!config.local; + this.fullZones = config.fullZones; + this.shortZones = config.shortZones; + if ( config.defaultDate instanceof Date ) { + this.defaultDate = config.defaultDate; + } else { + this.defaultDate = new Date(); + if ( this.local ) { + this.defaultDate.setMilliseconds( 0 ); + } else { + this.defaultDate.setUTCMilliseconds( 0 ); + } + } + }; + + /* Setup */ + + OO.initClass( mw.widgets.datetime.DateTimeFormatter ); + OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter ); + + /* Static */ + + /** + * Default format specifications. See the {@link #format format} parameter. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].formats = {}; + + /** + * Default time zone indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].fullZones = null; + + /** + * Default abbreviated time zone indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].shortZones = null; + + mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults = function () { + if ( !this.fullZones ) { + this.fullZones = [ + mw.msg( 'timezone-utc' ), + mw.msg( 'timezone-local' ) + ]; + } + if ( !this.shortZones ) { + this.shortZones = [ + 'Z', + this.fullZones[ 1 ].substr( 0, 1 ).toUpperCase() + ]; + if ( this.shortZones[ 1 ] === 'Z' ) { + this.shortZones[ 1 ] = 'L'; + } + } + }; + + /* Events */ + + /** + * A `local` event is emitted when the 'local' flag is changed. + * + * @event local + */ + + /* Methods */ + + /** + * Whether dates are in local time or UTC + * + * @return {boolean} True if local time + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () { + return this.local; + }; + + /** + * Toggle whether dates are in local time or UTC + * + * @param {boolean} [flag] Set the flag instead of toggling it + * @fires local + * @chainable + */ + mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) { + if ( flag === undefined ) { + flag = !this.local; + } else { + flag = !!flag; + } + if ( this.local !== flag ) { + this.local = flag; + this.emit( 'local', this.local ); + } + return this; + }; + + /** + * Get the default date + * + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () { + return new Date( this.defaultDate.getTime() ); + }; + + /** + * Fetch the field specification array for this object. + * + * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure. + * + * @return {Array} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () { + return this.parseFieldSpec( this.format ); + }; + + /** + * Parse a format string into a field specification + * + * The input is a string containing tags formatted as ${tag|param|param...} + * (for editable fields) and $!{tag|param|param...} (for non-editable fields). + * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few + * are defined here: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * + * Elements of the returned array are strings or objects. Strings are meant to + * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}. + * + * @protected + * @param {string} format + * @return {Array} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) { + var m, last, tag, params, spec, + ret = [], + re = /(.*?)(\$(!?)\{([^}]+)\})/g; + + last = 0; + while ( ( m = re.exec( format ) ) !== null ) { + last = re.lastIndex; + + if ( m[ 1 ] !== '' ) { + ret.push( m[ 1 ] ); + } + + params = m[ 4 ].split( '|' ); + tag = params.shift(); + spec = this.getFieldForTag( tag, params ); + if ( spec ) { + if ( m[ 3 ] === '!' ) { + spec.editable = false; + } + ret.push( spec ); + } else { + ret.push( m[ 2 ] ); + } + } + if ( last < format.length ) { + ret.push( format.substr( last ) ); + } + + return ret; + }; + + /** + * Turn a tag into a field specification object + * + * Fields implemented here are: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * - ${zone|#}: Timezone offset, "+0000" format. + * - ${zone|:}: Timezone offset, "+00:00" format. + * - ${zone|short}: Timezone from 'shortZones' configuration setting. + * - ${zone|full}: Timezone from 'fullZones' configuration setting. + * + * @protected + * @abstract + * @param {string} tag + * @param {string[]} params + * @return {Object|null} Field specification object, or null if the tag+params are unrecognized. + * @return {string|null} return.component Date component corresponding to this field, if any. + * @return {boolean} return.editable Whether this field is editable. + * @return {string} return.type What kind of field this is: + * - 'static': The field is a static string; component will be null. + * - 'number': The field is generally numeric. + * - 'string': The field is generally textual. + * - 'boolean': The field is a boolean. + * - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}. + * Editing should directly call {@link #toggleLocal this.toggleLocal()}. + * @return {number} return.size Maximum number of characters in the field (when + * the 'intercalary' component is falsey). If 0, the field should be hidden entirely. + * @return {Object.} return.intercalarySize Map from + * 'intercalary' component values to overridden sizes. + * @return {string} return.value For type='static', the string to display. + * @return {function(Mixed): string} return.formatValue A function to format a + * component value as a display string. + * @return {function(string): Mixed} return.parseValue A function to parse a + * display string into a component value. If parsing fails, returns undefined. + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var c, spec = null; + + switch ( tag ) { + case 'intercalary': + case 'not-intercalary': + if ( params.length < 2 || !params[ 0 ] ) { + return null; + } + spec = { + component: null, + editable: false, + type: 'static', + value: params.slice( 1 ).join( '|' ), + size: 0, + intercalarySize: {} + }; + if ( tag === 'intercalary' ) { + spec.intercalarySize[ params[ 0 ] ] = spec.value.length; + } else { + spec.size = spec.value.length; + spec.intercalarySize[ params[ 0 ] ] = 0; + } + return spec; + + case 'zone': + switch ( params[ 0 ] ) { + case '#': + case ':': + c = params[ 0 ] === '#' ? '' : ':'; + return { + component: 'zone', + editable: true, + type: 'toggleLocal', + size: 5 + c.length, + formatValue: function ( v ) { + var o, r; + if ( v ) { + o = new Date().getTimezoneOffset(); + r = String( Math.abs( o ) % 60 ); + while ( r.length < 2 ) { + r = '0' + r; + } + r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r; + while ( r.length < 4 + c.length ) { + r = '0' + r; + } + return ( o <= 0 ? '+' : '−' ) + r; + } else { + return '+00' + c + '00'; + } + }, + parseValue: function ( v ) { + var m; + v = String( v ).trim(); + if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) { + return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 ); + } else { + return undefined; + } + } + }; + + case 'short': + case 'full': + spec = { + component: 'zone', + editable: true, + type: 'toggleLocal', + values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones, + formatValue: this.formatSpecValue, + parseValue: this.parseSpecValue + }; + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + return spec; + } + return null; + + default: + return null; + } + }; + + /** + * Format a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'formatValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * - zeropad: Whether to pad the number with zeros. + * + * @protected + * @param {Mixed} v + * @return {string} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) { + if ( v === undefined || v === null ) { + return ''; + } + + if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) { + v = v ? 1 : 0; + } + + if ( this.values ) { + return this.values[ v ]; + } + + v = String( v ); + if ( this.zeropad ) { + while ( v.length < this.size ) { + v = '0' + v; + } + } + return v; + }; + + /** + * Parse a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'parseValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * + * @protected + * @param {string} v + * @return {number|string|null} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) { + var k, re; + + if ( v === '' ) { + return null; + } + + if ( !this.values ) { + v = +v; + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return isNaN( v ) ? undefined : !!v; + } else { + return isNaN( v ) ? undefined : v; + } + } + + if ( v.normalize ) { + v = v.normalize(); + } + re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' ); + for ( k in this.values ) { + k = +k; + if ( !isNaN( k ) && re.test( this.values[ k ] ) ) { + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return !!k; + } else { + return k; + } + } + } + return undefined; + }; + + /** + * Get components from a Date object + * + * Most specific components are defined by the subclass. "Global" components + * are: + * - intercalary: {string} Non-falsey values are used to indicate intercalary days. + * - zone: {number} Timezone offset in minutes. + * + * @abstract + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + // Should be overridden by subclass + return { + zone: this.local ? date.getTimezoneOffset() : 0 + }; + }; + + /** + * Get a Date object from components + * + * @param {Object} components Date components + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) { + // Should be overridden by subclass + return new Date(); + }; + + /** + * Adjust a date + * + * @param {Date|null} date To be adjusted + * @param {string} component To adjust + * @param {number} delta Adjustment amount + * @param {string} mode Adjustment mode: + * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc. + * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc. + * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc. + * @return {Date} Adjusted date + */ + mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /*, component, delta, mode */ ) { + // Should be overridden by subclass + return date; + }; + + /** + * Get the column headings (weekday abbreviations) for a calendar grid + * + * Null-valued columns are hidden if getCalendarData() returns no "day" object + * for all days in that column. + * + * @abstract + * @return {Array} string or null + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () { + // Should be overridden by subclass + return []; + }; + + /** + * Test whether two dates are in the same calendar grid + * + * @abstract + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + // Should be overridden by subclass + return date1.getTime() === date2.getTime(); + }; + + /** + * Test whether the date parts of two Dates are equal + * + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) { + if ( this.local ) { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + } else { + return ( + date1.getUTCFullYear() === date2.getUTCFullYear() && + date1.getUTCMonth() === date2.getUTCMonth() && + date1.getUTCDate() === date2.getUTCDate() + ); + } + }; + + /** + * Test whether the time parts of two Dates are equal + * + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) { + if ( this.local ) { + return ( + date1.getHours() === date2.getHours() && + date1.getMinutes() === date2.getMinutes() && + date1.getSeconds() === date2.getSeconds() && + date1.getMilliseconds() === date2.getMilliseconds() + ); + } else { + return ( + date1.getUTCHours() === date2.getUTCHours() && + date1.getUTCMinutes() === date2.getUTCMinutes() && + date1.getUTCSeconds() === date2.getUTCSeconds() && + date1.getUTCMilliseconds() === date2.getUTCMilliseconds() + ); + } + }; + + /** + * Test whether toggleLocal() changes the date part + * + * @param {Date} date + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) { + return ( + date.getUTCFullYear() !== date.getFullYear() || + date.getUTCMonth() !== date.getMonth() || + date.getUTCDate() !== date.getDate() + ); + }; + + /** + * Create a new Date by merging the date part from one with the time part from + * another. + * + * @param {Date} datepart + * @param {Date} timepart + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) { + var ret = new Date( datepart.getTime() ); + + if ( this.local ) { + ret.setHours( + timepart.getHours(), + timepart.getMinutes(), + timepart.getSeconds(), + timepart.getMilliseconds() + ); + } else { + ret.setUTCHours( + timepart.getUTCHours(), + timepart.getUTCMinutes(), + timepart.getUTCSeconds(), + timepart.getUTCMilliseconds() + ); + } + + return ret; + }; + + /** + * Get data for a calendar grid + * + * A "day" object is: + * - display: {string} Display text for the day. + * - date: {Date} Date to use when the day is selected. + * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks + * at the start and end of the month. + * + * In any one result object, 'extra' + 'display' will always be unique. + * + * @abstract + * @param {Date|null} current Current date + * @return {Object} Data + * @return {string} return.header String to display as the calendar header + * @return {string} return.monthComponent Component to adjust by ±1 to change months. + * @return {string} return.dayComponent Component to adjust by ±1 to change days. + * @return {string} [return.weekComponent] Component to adjust by ±1 to change + * weeks. If omitted, the dayComponent should be adjusted by ±the number of + * non-nullable columns returned by this.getCalendarHeadings() to change weeks. + * @return {Array} return.rows Array of arrays of "day" objects or null/undefined. + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) { + // Should be overridden by subclass + return { + header: '', + monthComponent: 'month', + dayComponent: 'day', + rows: [] + }; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js new file mode 100644 index 0000000000..df148c7403 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js @@ -0,0 +1,812 @@ +( function ( $, mw ) { + + /** + * DateTimeInputWidgets can be used to input a date, a time, or a date and + * time, in either UTC or the user's local timezone. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * This widget can be used inside a HTML form, such as a OO.ui.FormLayout. + * + * @example + * // Example of a text input widget + * var dateTimeInput = new mw.widgets.datetime.DateTimeInputWidget( {} ) + * $( 'body' ).append( dateTimeInput.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs + * + * @class + * @extends OO.ui.InputWidget + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [type='datetime'] Whether to act like a 'date', 'time', or 'datetime' input. + * Affects values stored in the relevant and the formatting and + * interpretation of values passed to/from getValue() and setValue(). It's up + * to the user to configure the DateTimeFormatter correctly. + * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for + * mw.widgets.datetime.ProlepticGregorianDateTimeFormatter (with 'format' defaulting to + * '@date', '@time', or '@datetime' depending on 'type'), or an + * mw.widgets.datetime.DateTimeFormatter instance to use. + * @cfg {Object|null} [calendar={}] Configuration options for + * mw.widgets.datetime.CalendarWidget; note certain settings will be forced based on the + * settings passed to this widget. Set null to disable the calendar. + * @cfg {boolean} [required=false] Whether a value is required. + * @cfg {boolean} [clearable=true] Whether to provide for blanking the value. + * @cfg {Date|null} [value=null] Default value for the widget + * @cfg {Date|string|null} [min=null] Minimum allowed date + * @cfg {Date|string|null} [max=null] Maximum allowed date + */ + mw.widgets.datetime.DateTimeInputWidget = function MwWidgetsDatetimeDateTimeInputWidget( config ) { + // Configuration initialization + config = $.extend( { + type: 'datetime', + clearable: true, + required: false, + min: null, + max: null, + formatter: {}, + calendar: {} + }, config ); + + if ( $.isPlainObject( config.formatter ) && config.formatter.format === undefined ) { + config.formatter.format = '@' + config.type; + } + + // Parent constructor + mw.widgets.datetime.DateTimeInputWidget[ 'super' ].call( this, config ); + + // Mixin constructors + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.IndicatorElement.call( this, config ); + OO.ui.mixin.PendingElement.call( this, config ); + + // Properties + this.type = config.type; + this.$handle = $( '' ); + this.$fields = $( '' ); + this.fields = []; + this.clearable = !!config.clearable; + this.required = !!config.required; + + if ( typeof config.min === 'string' ) { + config.min = this.parseDateValue( config.min ); + } + if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) { + this.min = config.min; + } else { + this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z + } + + if ( typeof config.max === 'string' ) { + config.max = this.parseDateValue( config.max ); + } + if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) { + this.max = config.max; + } else { + this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z + } + + switch ( this.type ) { + case 'date': + this.min.setUTCHours( 0, 0, 0, 0 ); + this.max.setUTCHours( 23, 59, 59, 999 ); + break; + case 'time': + this.min.setUTCFullYear( 1970, 0, 1 ); + this.max.setUTCFullYear( 1970, 0, 1 ); + break; + } + if ( this.min > this.max ) { + throw new Error( + '"min" (' + this.min.toISOString() + ') must not be greater than ' + + '"max" (' + this.max.toISOString() + ')' + ); + } + + if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) { + this.formatter = config.formatter; + } else if ( $.isPlainObject( config.formatter ) ) { + this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter ); + } else { + throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' ); + } + + if ( this.type === 'time' || config.calendar === null ) { + this.calendar = null; + } else { + config.calendar = $.extend( {}, config.calendar, { + formatter: this.formatter, + widget: this, + min: this.min, + max: this.max + } ); + this.calendar = new mw.widgets.datetime.CalendarWidget( config.calendar ); + } + + // Events + this.$handle.on( { + click: this.onHandleClick.bind( this ) + } ); + this.connect( this, { + change: 'onChange' + } ); + this.formatter.connect( this, { + local: 'onChange' + } ); + if ( this.calendar ) { + this.calendar.connect( this, { + change: 'onCalendarChange' + } ); + } + + // Initialization + this.setTabIndex( -1 ); + + this.$fields.addClass( 'mw-widgets-datetime-dateTimeInputWidget-fields' ); + this.setupFields(); + + this.$handle + .addClass( 'mw-widgets-datetime-dateTimeInputWidget-handle' ) + .append( this.$icon, this.$indicator, this.$fields ); + + this.$element + .addClass( 'mw-widgets-datetime-dateTimeInputWidget' ) + .append( this.$handle ); + + if ( this.calendar ) { + this.$element.append( this.calendar.$element ); + } + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.InputWidget ); + OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IconElement ); + OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IndicatorElement ); + OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.PendingElement ); + + /* Static properties */ + + mw.widgets.datetime.DateTimeInputWidget[ 'static' ].supportsSimpleLabel = false; + + /* Events */ + + /* Methods */ + + /** + * Convert a date string to a Date + * + * @private + * @param {string} value + * @return {Date|null} + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.parseDateValue = function ( value ) { + var date, m; + + value = String( value ); + switch ( this.type ) { + case 'date': + value = value + 'T00:00:00Z'; + break; + case 'time': + value = '1970-01-01T' + value + 'Z'; + break; + } + m = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/.exec( value ); + if ( m ) { + if ( m[ 7 ] ) { + while ( m[ 7 ].length < 3 ) { + m[ 7 ] += '0'; + } + } else { + m[ 7 ] = 0; + } + date = new Date(); + date.setUTCFullYear( m[ 1 ], m[ 2 ] - 1, m[ 3 ] ); + date.setUTCHours( m[ 4 ], m[ 5 ], m[ 6 ], m[ 7 ] ); + if ( date.getTime() < -62167219200000 || date.getTime() > 253402300799999 || + date.getUTCFullYear() !== +m[ 1 ] || + date.getUTCMonth() + 1 !== +m[ 2 ] || + date.getUTCDate() !== +m[ 3 ] || + date.getUTCHours() !== +m[ 4 ] || + date.getUTCMinutes() !== +m[ 5 ] || + date.getUTCSeconds() !== +m[ 6 ] || + date.getUTCMilliseconds() !== +m[ 7 ] + ) { + date = null; + } + } else { + date = null; + } + + return date; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.cleanUpValue = function ( value ) { + var date, pad; + + if ( value === '' ) { + return ''; + } + + if ( value instanceof Date ) { + date = value; + } else { + date = this.parseDateValue( value ); + } + + if ( date instanceof Date ) { + pad = function ( v, l ) { + v = String( v ); + while ( v.length < l ) { + v = '0' + v; + } + return v; + }; + + switch ( this.type ) { + case 'date': + value = pad( date.getUTCFullYear(), 4 ) + + '-' + pad( date.getUTCMonth() + 1, 2 ) + + '-' + pad( date.getUTCDate(), 2 ); + break; + + case 'time': + value = pad( date.getUTCHours(), 2 ) + + ':' + pad( date.getUTCMinutes(), 2 ) + + ':' + pad( date.getUTCSeconds(), 2 ) + + '.' + pad( date.getUTCMilliseconds(), 3 ); + value = value.replace( /\.?0+$/, '' ); + break; + + default: + value = date.toISOString(); + break; + } + } else { + value = ''; + } + + return value; + }; + + /** + * Get the value of the input as a Date object + * + * @return {Date|null} + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.getValueAsDate = function () { + return this.parseDateValue( this.getValue() ); + }; + + /** + * Set up the UI fields + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.setupFields = function () { + var i, $field, spec, placeholder, sz, maxlength, + spanValFunc = function ( v ) { + if ( v === undefined ) { + return this.data( 'mw-widgets-datetime-dateTimeInputWidget-value' ); + } else { + v = String( v ); + this.data( 'mw-widgets-datetime-dateTimeInputWidget-value', v ); + if ( v === '' ) { + v = this.data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder' ); + } + this.text( v ); + return this; + } + }, + reduceFunc = function ( k, v ) { + maxlength = Math.max( maxlength, v ); + }, + disabled = this.isDisabled(), + specs = this.formatter.getFieldSpec(); + + this.$fields.empty(); + this.clearButton = null; + this.fields = []; + + for ( i = 0; i < specs.length; i++ ) { + spec = specs[ i ]; + if ( typeof spec === 'string' ) { + $( '' ) + .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' ) + .text( spec ) + .appendTo( this.$fields ); + continue; + } + + placeholder = ''; + while ( placeholder.length < spec.size ) { + placeholder += '_'; + } + + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width. But we need some extra for + // IE, apparently. + sz = ( spec.size * 1.15 ) + 'ch'; + } else { + // Add a little for padding + sz = ( spec.size * 1.15 ) + 'ch'; + } + if ( spec.editable && spec.type !== 'static' ) { + if ( spec.type === 'boolean' || spec.type === 'toggleLocal' ) { + $field = $( '' ) + .attr( { + tabindex: disabled ? -1 : 0 + } ) + .width( sz ) + .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder ); + $field.on( { + keydown: this.onFieldKeyDown.bind( this, $field ), + focus: this.onFieldFocus.bind( this, $field ), + click: this.onFieldClick.bind( this, $field ), + 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field ) + } ); + $field.val = spanValFunc; + } else { + maxlength = spec.size; + if ( spec.intercalarySize ) { + $.each( spec.intercalarySize, reduceFunc ); + } + $field = $( '' ) + .attr( { + tabindex: disabled ? -1 : 0, + size: spec.size, + maxlength: maxlength + } ) + .prop( { + disabled: disabled, + placeholder: placeholder + } ) + .width( sz ); + $field.on( { + keydown: this.onFieldKeyDown.bind( this, $field ), + click: this.onFieldClick.bind( this, $field ), + focus: this.onFieldFocus.bind( this, $field ), + blur: this.onFieldBlur.bind( this, $field ), + change: this.onFieldChange.bind( this, $field ), + 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field ) + } ); + } + $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-editField' ); + } else { + $field = $( '' ) + .width( sz ) + .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder ); + if ( spec.type === 'static' ) { + $field.text( spec.value ); + } else { + $field.val = spanValFunc; + } + } + + this.fields.push( $field ); + $field + .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' ) + .data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec', spec ) + .appendTo( this.$fields ); + } + + if ( this.clearable ) { + this.clearButton = new OO.ui.ButtonWidget( { + classes: [ 'mw-widgets-datetime-dateTimeInputWidget-field', 'mw-widgets-datetime-dateTimeInputWidget-clearButton' ], + framed: false, + icon: 'remove', + disabled: disabled + } ).connect( this, { + click: 'onClearClick' + } ); + this.$fields.append( this.clearButton.$element ); + } + + this.updateFieldsFromValue(); + }; + + /** + * Update the UI fields from the current value + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.updateFieldsFromValue = function () { + var i, $field, spec, intercalary, sz, + date = this.getValueAsDate(); + + if ( date === null ) { + this.components = null; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[ i ]; + spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + + $field + .removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid oo-ui-element-hidden' ) + .val( '' ); + + if ( spec.intercalarySize ) { + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width. But we need some extra for + // IE, apparently. + $field.width( ( spec.size * 1.15 ) + 'ch' ); + } else { + // Add a little for padding + $field.width( ( spec.size * 1.15 ) + 'ch' ); + } + } + } + + this.setFlags( { invalid: this.required } ); + } else { + this.components = this.formatter.getComponentsFromDate( date ); + intercalary = this.components.intercalary; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[ i ]; + $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' ); + spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + if ( spec.type !== 'static' ) { + $field.val( spec.formatValue( this.components[ spec.component ] ) ); + } + if ( spec.intercalarySize ) { + if ( intercalary && spec.intercalarySize[ intercalary ] !== undefined ) { + sz = spec.intercalarySize[ intercalary ]; + } else { + sz = spec.size; + } + $field.toggleClass( 'oo-ui-element-hidden', sz <= 0 ); + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width. But we need some extra for + // IE, apparently. + this.fields[ i ].width( ( sz * 1.15 ) + 'ch' ); + } else { + // Add a little for padding + this.fields[ i ].width( ( sz * 1.15 ) + 'ch' ); + } + } + } + + this.setFlags( { invalid: date < this.min || date > this.max } ); + } + + this.$element.toggleClass( 'mw-widgets-datetime-dateTimeInputWidget-empty', date === null ); + }; + + /** + * Update the value with data from the UI fields + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.updateValueFromFields = function () { + var i, v, $field, spec, curDate, newDate, + components = {}, + anyInvalid = false, + anyEmpty = false, + allEmpty = true; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[ i ]; + spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + if ( spec.editable ) { + $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' ); + v = $field.val(); + if ( v === '' ) { + $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' ); + anyEmpty = true; + } else { + allEmpty = false; + v = spec.parseValue( v ); + if ( v === undefined ) { + $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' ); + anyInvalid = true; + } else { + components[ spec.component ] = v; + } + } + } + } + + if ( allEmpty ) { + for ( i = 0; i < this.fields.length; i++ ) { + this.fields[ i ].removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' ); + } + } else if ( anyEmpty ) { + anyInvalid = true; + } + + if ( !anyInvalid ) { + curDate = this.getValueAsDate(); + newDate = this.formatter.getDateFromComponents( components ); + if ( !curDate || !newDate || curDate.getTime() !== newDate.getTime() ) { + this.setValue( newDate ); + } + } + }; + + /** + * Handle change event + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onChange = function () { + var date; + + this.updateFieldsFromValue(); + + if ( this.calendar ) { + date = this.getValueAsDate(); + this.calendar.setSelected( date ); + if ( date ) { + this.calendar.setFocusedDate( date ); + } + } + }; + + /** + * Handle clear button click event + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onClearClick = function () { + this.blur(); + this.setValue( '' ); + }; + + /** + * Handle click on the widget background + * + * @private + * @param {jQuery.Event} e Click event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onHandleClick = function () { + this.focus(); + }; + + /** + * Handle key down events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Key down event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldKeyDown = function ( $field, e ) { + var spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case OO.ui.Keys.ENTER: + case OO.ui.Keys.SPACE: + if ( spec.type === 'boolean' ) { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' ) + ); + return false; + } else if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } + break; + + case OO.ui.Keys.UP: + case OO.ui.Keys.DOWN: + if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } else { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, + e.keyCode === OO.ui.Keys.UP ? -1 : 1, 'wrap' ) + ); + } + if ( $field.is( ':input' ) ) { + $field.select(); + } + return false; + } + } + }; + + /** + * Handle focus events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Focus event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldFocus = function ( $field ) { + if ( !this.isDisabled() ) { + if ( this.getValueAsDate() === null ) { + this.setValue( this.formatter.getDefaultDate() ); + } + if ( $field.is( ':input' ) ) { + $field.select(); + } + + if ( this.calendar ) { + this.calendar.toggle( true ); + } + } + }; + + /** + * Handle click events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Click event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldClick = function ( $field ) { + var spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + + if ( !this.isDisabled() ) { + if ( spec.type === 'boolean' ) { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' ) + ); + } else if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } + } + }; + + /** + * Handle blur events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Blur event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldBlur = function ( $field ) { + var v, date, + spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + + this.updateValueFromFields(); + + // Normalize + date = this.getValueAsDate(); + if ( !date ) { + $field.val( '' ); + } else { + v = spec.formatValue( this.formatter.getComponentsFromDate( date )[ spec.component ] ); + if ( v !== $field.val() ) { + $field.val( v ); + } + } + }; + + /** + * Handle change events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Change event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldChange = function () { + this.updateValueFromFields(); + }; + + /** + * Handle wheel events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Change event + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldWheel = function ( $field, e ) { + var delta = 0, + spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' ); + + if ( this.isDisabled() ) { + return; + } + + // Standard 'wheel' event + if ( e.originalEvent.deltaMode !== undefined ) { + this.sawWheelEvent = true; + } + if ( e.originalEvent.deltaY ) { + delta = -e.originalEvent.deltaY; + } else if ( e.originalEvent.deltaX ) { + delta = e.originalEvent.deltaX; + } + + // Non-standard events + if ( !this.sawWheelEvent ) { + if ( e.originalEvent.wheelDeltaX ) { + delta = -e.originalEvent.wheelDeltaX; + } else if ( e.originalEvent.wheelDeltaY ) { + delta = e.originalEvent.wheelDeltaY; + } else if ( e.originalEvent.wheelDelta ) { + delta = e.originalEvent.wheelDelta; + } else if ( e.originalEvent.detail ) { + delta = -e.originalEvent.detail; + } + } + + if ( delta && spec ) { + if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } else { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, delta < 0 ? -1 : 1, 'wrap' ) + ); + } + return false; + } + }; + + /** + * Handle calendar change event + * + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.onCalendarChange = function () { + var curDate = this.getValueAsDate(), + newDate = this.calendar.getSelected()[ 0 ]; + + if ( newDate ) { + if ( !curDate || newDate.getTime() !== curDate.getTime() ) { + this.setValue( newDate ); + } + } + }; + + /** + * @inheritdoc + * @private + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.getInputElement = function () { + return $( '' ); + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) { + mw.widgets.datetime.DateTimeInputWidget[ 'super' ].prototype.setDisabled.call( this, disabled ); + + // Flag all our fields as disabled + if ( this.$fields ) { + this.$fields.find( 'input' ).prop( 'disabled', this.isDisabled() ); + this.$fields.find( '[tabindex]' ).attr( 'tabindex', this.isDisabled() ? -1 : 0 ); + } + + if ( this.clearButton ) { + this.clearButton.setDisabled( disabled ); + } + + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.focus = function () { + if ( !this.$fields.find( document.activeElement ).length ) { + this.$fields.find( '.mw-widgets-datetime-dateTimeInputWidget-editField' ).first().focus(); + } + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.blur = function () { + this.$fields.find( document.activeElement ).blur(); + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.simulateLabelClick = function () { + this.focus(); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less new file mode 100644 index 0000000000..bc387df97f --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less @@ -0,0 +1,155 @@ +@import "mediawiki.widgets.datetime.definitions"; + +.mw-widgets-datetime-dateTimeInputWidget { + display: inline-block; + position: relative; + vertical-align: middle; + + &-fields { + position: relative; + display: table; + z-index: 2; + .oo-ui-unselectable(); + + > .mw-widgets-datetime-dateTimeInputWidget-field { + .oo-ui-box-sizing(border-box); + + display: table-cell; + white-space: pre; + } + } + + &-handle { + width: 100%; + display: inline-block; + overflow: hidden; + + // Needed for proper behavior with overflow: hidden. + vertical-align: bottom; + + .oo-ui-unselectable(); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator, + > .oo-ui-iconElement-icon { + position: absolute; + background-position: center center; + background-repeat: no-repeat; + z-index: 1; + } + } + + margin: 0.25em 0; + width: 100%; + max-width: 50em; + + .oo-ui-inline-spacing(0.5em); + + &-handle { + height: 2.5em; + border: 1px solid #ccc; + padding: 0 1em; + margin: 0; + background-color: #fff; + color: black; + border: solid 1px #ccc; + box-shadow: inset 0 0 0 0 @progressive; + border-radius: 0.1em; + .oo-ui-transition(box-shadow @quick-ease); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator { + right: 0; + } + + > .oo-ui-iconElement-icon { + left: 0.25em; + } + + > .oo-ui-indicatorElement-indicator { + top: 0; + width: @indicator-size; + height: @indicator-size; + margin: 0.775em; + } + + > .oo-ui-iconElement-icon { + top: 0; + width: @icon-size; + height: @icon-size; + margin: 0.3em; + } + } + + &-empty &-handle { + color: #777; + } + + &-field { + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + background-color: transparent; + color: inherit; + border: none; + box-shadow: none; + text-align: center; + vertical-align: middle; + .oo-ui-box-sizing(border-box); + } + + &.oo-ui-widget-disabled { + .mw-widgets-datetime-dateTimeInputWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + + > .oo-ui-iconElement-icon, + > .oo-ui-indicatorElement-indicator { + opacity: 0.2; + } + } + } + + &.oo-ui-widget-enabled { + .mw-widgets-datetime-dateTimeInputWidget-editField:hover { + background-color: #eee; + } + + &.oo-ui-flaggedElement-invalid { + .mw-widgets-datetime-dateTimeInputWidget-handle { + border-color: red; + box-shadow: inset 0 0 0 0 red; + } + + .mw-widgets-datetime-dateTimeInputWidget-handle:focus { + border-color: red; + box-shadow: inset 0 0 0 0.1em red; + } + } + } + + input.mw-widgets-datetime-dateTimeInputWidget-field { + padding: 0.5em 0; + } + + &-editField.mw-widgets-datetime-dateTimeInputWidget-invalid { + border: 1px solid red; + box-shadow: inset 0 0 0 0 red; + + &:focus { + border: 1px solid red; + box-shadow: inset 0 0 0 0.1em red; + } + } + + &.oo-ui-iconElement .mw-widgets-datetime-dateTimeInputWidget-handle { + padding-left: 3em; + } + + &.oo-ui-indicatorElement .mw-widgets-datetime-dateTimeInputWidget-handle { + padding-right: 2em; + } +} diff --git a/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js new file mode 100644 index 0000000000..fbf323844b --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js @@ -0,0 +1,562 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. This + * implementation implments the [Discordian calendar][1], mainly for testing with + * something very different from the usual Gregorian calendar. + * + * Being intended mainly for testing, niceties like i18n and better + * configurability have been omitted. + * + * [1]: https://en.wikipedia.org/wiki/Discordian_calendar + * + * @class + * @extends mw.widgets.datetime.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + */ + mw.widgets.datetime.DiscordianDateTimeFormatter = function MwWidgetsDatetimeDiscordianDateTimeFormatter( config ) { + config = $.extend( {}, config ); + + // Parent constructor + mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].call( this, config ); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.DiscordianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter ); + + /* Static */ + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter[ 'static' ].formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#}', + '@datetime': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' + }; + + /* Methods */ + + /** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${season|#}: Season as a number + * - ${season|full}: Season as a string + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|full}: Day of the week as a string + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[ 0 ] ) { + case 'year|#': + spec = { + component: 'Year', + type: 'number', + size: 4, + zeropad: false + }; + break; + + case 'season|#': + spec = { + component: 'Season', + type: 'number', + size: 1, + intercalarySize: { 1: 0 }, + zeropad: false + }; + break; + + case 'season|full': + spec = { + component: 'Season', + type: 'string', + intercalarySize: { 1: 0 }, + values: { + 1: 'Chaos', + 2: 'Discord', + 3: 'Confusion', + 4: 'Bureaucracy', + 5: 'The Aftermath' + } + }; + break; + + case 'dow|full': + spec = { + component: 'DOW', + editable: false, + type: 'string', + intercalarySize: { 1: 0 }, + values: { + '-1': 'N/A', + 0: 'Sweetmorn', + 1: 'Boomtime', + 2: 'Pungenday', + 3: 'Prickle-Prickle', + 4: 'Setting Orange' + } + }; + break; + + case 'day|#': + case 'day|0': + spec = { + component: 'Day', + type: 'string', + size: 2, + intercalarySize: { 1: 13 }, + zeropad: params[ 0 ] === '0', + formatValue: function ( v ) { + if ( v === 'tib' ) { + return 'St. Tib\'s Day'; + } + return mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue.call( this, v ); + }, + parseValue: function ( v ) { + if ( /^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) { + return 'tib'; + } + return mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue.call( this, v ); + } + }; + break; + + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag.charAt( 0 ).toUpperCase() + tag.slice( 1 ), + type: 'number', + size: 2, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'Millisecond', + type: 'number', + size: 3, + zeropad: params[ 0 ] === '0' + }; + break; + + default: + return mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + if ( spec.component !== 'Day' ) { + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + } + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; + }; + + /** + * Get components from a Date object + * + * Components are: + * - Year {number} + * - Season {number} 1-5 + * - Day {number|string} 1-73 or 'tib' + * - DOW {number} 0-4, or -1 on St. Tib's Day + * - Hour {number} 0-23 + * - Minute {number} 0-59 + * - Second {number} 0-59 + * - Millisecond {number} 0-999 + * - intercalary {string} '1' on St. Tib's Day + * + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret, day, month, + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + day = date.getDate(); + month = date.getMonth(); + ret = { + Year: date.getFullYear() + 1166, + Hour: date.getHours(), + Minute: date.getMinutes(), + Second: date.getSeconds(), + Millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + day = date.getUTCDate(); + month = date.getUTCMonth(); + ret = { + Year: date.getUTCFullYear() + 1166, + Hour: date.getUTCHours(), + Minute: date.getUTCMinutes(), + Second: date.getUTCSeconds(), + Millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + if ( month === 1 && day === 29 ) { + ret.Season = 1; + ret.Day = 'tib'; + ret.DOW = -1; + ret.intercalary = '1'; + } else { + day = monthDays[ month ] + day - 1; + ret.Season = Math.floor( day / 73 ) + 1; + ret.Day = ( day % 73 ) + 1; + ret.DOW = day % 5; + ret.intercalary = ''; + } + + return ret; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + return this.getDateFromComponents( + this.adjustComponentInternal( + this.getComponentsFromDate( date ), component, delta, mode + ) + ); + }; + + /** + * Adjust the components directly + * + * @private + * @param {Object} components Modified in place + * @param {string} component + * @param {number} delta + * @param {string} mode + * @return {Object} components + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponentInternal = function ( components, component, delta, mode ) { + var i, min, max, range, next, preTib, postTib, wasTib; + + if ( delta === 0 ) { + return components; + } + + switch ( component ) { + case 'Year': + min = 1166; + max = 11165; + next = null; + break; + case 'Season': + min = 1; + max = 5; + next = 'Year'; + break; + case 'Week': + if ( components.Day === 'tib' ) { + components.Day = 59; // Could choose either one... + components.Season = 1; + } + min = 1; + max = 73; + next = 'Season'; + break; + case 'Day': + min = 1; + max = 73; + next = 'Season'; + break; + case 'Hour': + min = 0; + max = 23; + next = 'Day'; + break; + case 'Minute': + min = 0; + max = 59; + next = 'Hour'; + break; + case 'Second': + min = 0; + max = 59; + next = 'Minute'; + break; + case 'Millisecond': + min = 0; + max = 999; + next = 'Second'; + break; + default: + return components; + } + + switch ( mode ) { + case 'overflow': + case 'clip': + case 'wrap': + } + + if ( component === 'Day' ) { + i = Math.abs( delta ); + delta = delta < 0 ? -1 : 1; + preTib = delta > 0 ? 59 : 60; + postTib = delta > 0 ? 60 : 59; + while ( i-- > 0 ) { + if ( components.Day === preTib && components.Season === 1 && this.isLeapYear( components.Year ) ) { + components.Day = 'tib'; + } else if ( components.Day === 'tib' ) { + components.Day = postTib; + components.Season = 1; + } else { + components.Day += delta; + if ( components.Day < min ) { + switch ( mode ) { + case 'overflow': + components.Day = max; + this.adjustComponentInternal( components, 'Season', -1, mode ); + break; + case 'wrap': + components.Day = max; + break; + case 'clip': + components.Day = min; + i = 0; + break; + } + } + if ( components.Day > max ) { + switch ( mode ) { + case 'overflow': + components.Day = min; + this.adjustComponentInternal( components, 'Season', 1, mode ); + break; + case 'wrap': + components.Day = min; + break; + case 'clip': + components.Day = max; + i = 0; + break; + } + } + } + } + } else { + if ( component === 'Week' ) { + component = 'Day'; + delta *= 5; + } + if ( components.Day === 'tib' ) { + // For sanity + components.Season = 1; + } + switch ( mode ) { + case 'overflow': + if ( components.Day === 'tib' && ( component === 'Season' || component === 'Year' ) ) { + components.Day = 59; // Could choose either one... + wasTib = true; + } else { + wasTib = false; + } + i = Math.abs( delta ); + delta = delta < 0 ? -1 : 1; + while ( i-- > 0 ) { + components[ component ] += delta; + if ( components[ component ] < min ) { + components[ component ] = max; + components = this.adjustComponentInternal( components, next, -1, mode ); + } + if ( components[ component ] > max ) { + components[ component ] = min; + components = this.adjustComponentInternal( components, next, 1, mode ); + } + } + if ( wasTib && components.Season === 1 && this.isLeapYear( components.Year ) ) { + components.Day = 'tib'; + } + break; + case 'wrap': + range = max - min + 1; + components[ component ] += delta; + while ( components[ component ] < min ) { + components[ component ] += range; + } + while ( components[ component ] > max ) { + components[ component ] -= range; + } + break; + case 'clip': + components[ component ] += delta; + if ( components[ component ] < min ) { + components[ component ] = min; + } + if ( components[ component ] > max ) { + components[ component ] = max; + } + break; + } + if ( components.Day === 'tib' && + ( components.Season !== 1 || !this.isLeapYear( components.Year ) ) + ) { + components.Day = 59; // Could choose either one... + } + } + + return components; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var month, day, days, + date = new Date(), + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ]; + + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + if ( components.Day === 'tib' ) { + month = 1; + day = 29; + } else { + days = components.Season * 73 + components.Day - 74; + month = 0; + while ( days >= monthDays[ month + 1 ] ) { + month++; + } + day = days - monthDays[ month ] + 1; + } + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.Year - 1166, month, day ); + date.setHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.Year - 1166, month, day ); + date.setUTCHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } + + return date; + }; + + /** + * Get whether the year is a leap year + * + * @private + * @param {number} year + * @return {boolean} + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) { + year -= 1166; + if ( year % 4 ) { + return false; + } else if ( year % 100 ) { + return true; + } + return ( year % 400 ) === 0; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () { + return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ]; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + var components1 = this.getComponentsFromDate( date1 ), + components2 = this.getComponentsFromDate( date2 ); + + return components1.Year === components2.Year && components1.Season === components2.Season; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date ) { + var dt, components, season, i, row, + ret = { + dayComponent: 'Day', + weekComponent: 'Week', + monthComponent: 'Season' + }, + seasons = [ 'Chaos', 'Discord', 'Confusion', 'Bureaucracy', 'The Aftermath' ], + seasonStart = [ 0, -3, -1, -4, -2 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + components = this.getComponentsFromDate( date ); + components.Day = 1; + season = components.Season; + + ret.header = seasons[ season - 1 ] + ' ' + components.Year; + + if ( seasonStart[ season - 1 ] ) { + this.adjustComponentInternal( components, 'Day', seasonStart[ season - 1 ], 'overflow' ); + } + + ret.rows = []; + do { + row = []; + for ( i = 0; i < 6; i++ ) { + dt = this.getDateFromComponents( components ); + row[ i ] = { + display: components.Day === 'tib' ? 'Tib' : String( components.Day ), + date: dt, + extra: components.Season < season ? 'prev' : components.Season > season ? 'next' : null + }; + + this.adjustComponentInternal( components, 'Day', 1, 'overflow' ); + if ( components.Day !== 'tib' && i === 3 ) { + row[ ++i ] = null; + } + } + + ret.rows.push( row ); + } while ( components.Season === season ); + + return ret; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js new file mode 100644 index 0000000000..f60b34bdcc --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js @@ -0,0 +1,661 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. This + * implementation implments the proleptic Gregorian calendar over years + * 0000–9999. + * + * @class + * @extends mw.widgets.datetime.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} [fullMonthNames] Mapping 1–12 to full month names. + * @cfg {Object} [shortMonthNames] Mapping 1–12 to abbreviated month names. + * If {@link #fullMonthNames fullMonthNames} is given and this is not, + * defaults to the first three characters from that setting. + * @cfg {Object} [fullDayNames] Mapping 0–6 to full day of week names. 0 is Sunday, 6 is Saturday. + * @cfg {Object} [shortDayNames] Mapping 0–6 to abbreviated day of week names. 0 is Sunday, 6 is Saturday. + * If {@link #fullDayNames fullDayNames} is given and this is not, defaults to + * the first three characters from that setting. + * @cfg {string[]} [dayLetters] Weekday column headers for a calendar. Array of 7 strings. + * If {@link #fullDayNames fullDayNames} or {@link #shortDayNames shortDayNames} + * are given and this is not, defaults to the first character from + * shortDayNames. + * @cfg {string[]} [hour12Periods] AM and PM texts. Array of 2 strings, AM and PM. + * @cfg {number} [weekStartsOn=0] What day the week starts on: 0 is Sunday, 1 is Monday, 6 is Saturday. + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter = function MwWidgetsDatetimeProlepticGregorianDateTimeFormatter( config ) { + var statick = this.constructor[ 'static' ]; + + statick.setupDefaults(); + + config = $.extend( { + weekStartsOn: 0, + hour12Periods: statick.hour12Periods + }, config ); + + if ( config.fullMonthNames && !config.shortMonthNames ) { + config.shortMonthNames = {}; + $.each( config.fullMonthNames, function ( k, v ) { + config.shortMonthNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( config.shortDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.shortDayNames, function ( k, v ) { + config.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.fullDayNames, function ( k, v ) { + config.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.shortDayNames ) { + config.shortDayNames = {}; + $.each( config.fullDayNames, function ( k, v ) { + config.shortDayNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + config = $.extend( { + fullMonthNames: statick.fullMonthNames, + shortMonthNames: statick.shortMonthNames, + fullDayNames: statick.fullDayNames, + shortDayNames: statick.shortDayNames, + dayLetters: statick.dayLetters + }, config ); + + // Parent constructor + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].call( this, config ); + + // Properties + this.weekStartsOn = config.weekStartsOn % 7; + this.fullMonthNames = config.fullMonthNames; + this.shortMonthNames = config.shortMonthNames; + this.fullDayNames = config.fullDayNames; + this.shortDayNames = config.shortDayNames; + this.dayLetters = config.dayLetters; + this.hour12Periods = config.hour12Periods; + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter ); + + /* Static */ + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|short} ${day|#} ${month|short} ${year|#}', + '@datetime': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' + }; + + /** + * Default full month names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullMonthNames = null; + + /** + * Default abbreviated month names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortMonthNames = null; + + /** + * Default full day of week names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullDayNames = null; + + /** + * Default abbreviated day of week names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortDayNames = null; + + /** + * Default day letters. + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].dayLetters = null; + + /** + * Default AM/PM indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].hour12Periods = null; + + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].setupDefaults = function () { + mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults.call( this ); + + if ( this.fullMonthNames && !this.shortMonthNames ) { + this.shortMonthNames = {}; + $.each( this.fullMonthNames, function ( k, v ) { + this.shortMonthNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( this.shortDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.fullDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.shortDayNames ) { + this.shortDayNames = {}; + $.each( this.fullDayNames, function ( k, v ) { + this.shortDayNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + + if ( !this.fullMonthNames ) { + this.fullMonthNames = { + 1: mw.msg( 'january' ), + 2: mw.msg( 'february' ), + 3: mw.msg( 'march' ), + 4: mw.msg( 'april' ), + 5: mw.msg( 'may_long' ), + 6: mw.msg( 'june' ), + 7: mw.msg( 'july' ), + 8: mw.msg( 'august' ), + 9: mw.msg( 'september' ), + 10: mw.msg( 'october' ), + 11: mw.msg( 'november' ), + 12: mw.msg( 'december' ) + }; + } + if ( !this.shortMonthNames ) { + this.shortMonthNames = { + 1: mw.msg( 'jan' ), + 2: mw.msg( 'feb' ), + 3: mw.msg( 'mar' ), + 4: mw.msg( 'apr' ), + 5: mw.msg( 'may' ), + 6: mw.msg( 'jun' ), + 7: mw.msg( 'jul' ), + 8: mw.msg( 'aug' ), + 9: mw.msg( 'sep' ), + 10: mw.msg( 'oct' ), + 11: mw.msg( 'nov' ), + 12: mw.msg( 'dec' ) + }; + } + + if ( !this.fullDayNames ) { + this.fullDayNames = { + 0: mw.msg( 'sunday' ), + 1: mw.msg( 'monday' ), + 2: mw.msg( 'tuesday' ), + 3: mw.msg( 'wednesday' ), + 4: mw.msg( 'thursday' ), + 5: mw.msg( 'friday' ), + 6: mw.msg( 'saturday' ) + }; + } + if ( !this.shortDayNames ) { + this.shortDayNames = { + 0: mw.msg( 'sun' ), + 1: mw.msg( 'mon' ), + 2: mw.msg( 'tue' ), + 3: mw.msg( 'wed' ), + 4: mw.msg( 'thu' ), + 5: mw.msg( 'fri' ), + 6: mw.msg( 'sat' ) + }; + } + if ( !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + + if ( !this.hour12Periods ) { + this.hour12Periods = [ + mw.msg( 'period-am' ), + mw.msg( 'period-pm' ) + ]; + } + }; + + /* Methods */ + + /** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${year|0}: Year as a number, zero-padded to 4 digits + * - ${month|#}: Month as a number + * - ${month|0}: Month as a number with leading 0 + * - ${month|short}: Month from 'shortMonthNames' configuration setting + * - ${month|full}: Month from 'fullMonthNames' configuration setting + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|short}: Day of the week from 'shortDayNames' configuration setting + * - ${dow|full}: Day of the week from 'fullDayNames' configuration setting + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${hour|12}: Hour in a 12-hour clock as a number + * - ${hour|012}: Hour in a 12-hour clock as a number, with leading 0 + * - ${hour|period}: Value from 'hour12Periods' configuration setting + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[ 0 ] ) { + case 'year|#': + case 'year|0': + spec = { + component: 'year', + type: 'number', + size: 4, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'month|short': + case 'month|full': + spec = { + component: 'month', + type: 'string', + values: params[ 0 ] === 'short' ? this.shortMonthNames : this.fullMonthNames + }; + break; + + case 'dow|short': + case 'dow|full': + spec = { + component: 'dow', + editable: false, + type: 'string', + values: params[ 0 ] === 'short' ? this.shortDayNames : this.fullDayNames + }; + break; + + case 'month|#': + case 'month|0': + case 'day|#': + case 'day|0': + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag, + type: 'number', + size: 2, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'hour|12': + case 'hour|012': + spec = { + component: 'hour12', + type: 'number', + size: 2, + zeropad: params[ 0 ] === '012' + }; + break; + + case 'hour|period': + spec = { + component: 'hour12period', + type: 'boolean', + values: this.hour12Periods + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'millisecond', + type: 'number', + size: 3, + zeropad: params[ 0 ] === '0' + }; + break; + + default: + return mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; + }; + + /** + * Get components from a Date object + * + * Components are: + * - year {number} + * - month {number} (1-12) + * - day {number} (1-31) + * - dow {number} (0-6, 0 is Sunday) + * - hour {number} (0-23) + * - hour12 {number} (1-12) + * - hour12period {boolean} + * - minute {number} (0-59) + * - second {number} (0-59) + * - millisecond {number} (0-999) + * - zone {number} + * + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + ret = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + dow: date.getDay() % 7, + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + ret = { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + dow: date.getUTCDay() % 7, + hour: date.getUTCHours(), + minute: date.getUTCMinutes(), + second: date.getUTCSeconds(), + millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + ret.hour12period = ret.hour >= 12 ? 1 : 0; + ret.hour12 = ret.hour % 12; + if ( ret.hour12 === 0 ) { + ret.hour12 = 12; + } + + return ret; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var date = new Date(); + + components = $.extend( {}, components ); + if ( components.hour === undefined && components.hour12 !== undefined && components.hour12period !== undefined ) { + components.hour = ( components.hour12 % 12 ) + ( components.hour12period ? 12 : 0 ); + } + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.year, components.month - 1, components.day ); + date.setHours( components.hour, components.minute, components.second, components.millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.year, components.month - 1, components.day ); + date.setUTCHours( components.hour, components.minute, components.second, components.millisecond ); + } + + return date; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + var min, max, range, components; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + components = this.getComponentsFromDate( date ); + + switch ( component ) { + case 'year': + min = 0; + max = 9999; + break; + case 'month': + min = 1; + max = 12; + break; + case 'day': + min = 1; + max = this.getDaysInMonth( components.month, components.year ); + break; + case 'hour': + min = 0; + max = 23; + break; + case 'minute': + case 'second': + min = 0; + max = 59; + break; + case 'millisecond': + min = 0; + max = 999; + break; + case 'hour12period': + component = 'hour'; + min = 0; + max = 23; + delta *= 12; + break; + case 'hour12': + component = 'hour'; + min = components.hour12period ? 12 : 0; + max = components.hour12period ? 23 : 11; + break; + default: + return new Date( date.getTime() ); + } + + components[ component ] += delta; + range = max - min + 1; + switch ( mode ) { + case 'overflow': + // Date() will mostly handle it automatically. But months need + // manual handling to prevent e.g. Jan 31 => Mar 3. + if ( component === 'month' || component === 'year' ) { + while ( components.month < 1 ) { + components[ component ] += 12; + components.year--; + } + while ( components.month > 12 ) { + components[ component ] -= 12; + components.year++; + } + } + break; + case 'wrap': + while ( components[ component ] < min ) { + components[ component ] += range; + } + while ( components[ component ] > max ) { + components[ component ] -= range; + } + break; + case 'clip': + if ( components[ component ] < min ) { + components[ component ] = min; + } + if ( components[ component ] < max ) { + components[ component ] = max; + } + break; + } + if ( component === 'month' || component === 'year' ) { + components.day = Math.min( components.day, this.getDaysInMonth( components.month, components.year ) ); + } + + return this.getDateFromComponents( components ); + }; + + /** + * Get the number of days in a month + * + * @protected + * @param {number} month + * @param {number} year + * @return {number} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDaysInMonth = function ( month, year ) { + switch ( month ) { + case 4: + case 6: + case 9: + case 11: + return 30; + case 2: + if ( year % 4 ) { + return 28; + } else if ( year % 100 ) { + return 29; + } + return ( year % 400 ) ? 28 : 29; + default: + return 31; + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarHeadings = function () { + var a = this.dayLetters; + + if ( this.weekStartsOn ) { + return a.slice( this.weekStartsOn ).concat( a.slice( 0, this.weekStartsOn ) ); + } else { + return a.slice( 0 ); // clone + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + if ( this.local ) { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth(); + } else { + return date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getUTCMonth() === date2.getUTCMonth(); + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarData = function ( date ) { + var dt, t, d, e, i, row, + getDate = this.local ? 'getDate' : 'getUTCDate', + setDate = this.local ? 'setDate' : 'setUTCDate', + ret = { + dayComponent: 'day', + monthComponent: 'month' + }; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + dt = new Date( date.getTime() ); + dt[ setDate ]( 1 ); + t = dt.getTime(); + + if ( this.local ) { + ret.header = this.fullMonthNames[ dt.getMonth() + 1 ] + ' ' + dt.getFullYear(); + d = dt.getDay() % 7; + e = this.getDaysInMonth( dt.getMonth() + 1, dt.getFullYear() ); + } else { + ret.header = this.fullMonthNames[ dt.getUTCMonth() + 1 ] + ' ' + dt.getUTCFullYear(); + d = dt.getUTCDay() % 7; + e = this.getDaysInMonth( dt.getUTCMonth() + 1, dt.getUTCFullYear() ); + } + + if ( this.weekStartsOn ) { + d = ( d + 7 - this.weekStartsOn ) % 7; + } + d = 1 - d; + + ret.rows = []; + while ( d <= e ) { + row = []; + for ( i = 0; i < 7; i++, d++ ) { + dt = new Date( t ); + dt[ setDate ]( d ); + row[ i ] = { + display: String( dt[ getDate ]() ), + date: dt, + extra: d < 1 ? 'prev' : d > e ? 'next' : null + }; + } + ret.rows.push( row ); + } + + return ret; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less new file mode 100644 index 0000000000..ee0e66e2e2 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less @@ -0,0 +1,37 @@ +/*! + * OOJS-UI defines used by the existing CSS (will make it easier to put this + * widget in OOJS-UI once OOJS-UI is capable of handling it) + */ + +.oo-ui-box-sizing( @type: border-box ) { + -webkit-box-sizing: @type; + -moz-box-sizing: @type; + box-sizing: @type; +} + +.oo-ui-unselectable() { + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.oo-ui-inline-spacing( @spacing, @cancelled-spacing: 0 ) { + margin-right: @spacing; + &:last-child { + margin-right: @cancelled-spacing; + } +} + +.oo-ui-transition( @value1, @value2: X, ... ) { + @value: ~`"@{arguments}".replace(/[\[\]]|\,\sX/g, '')`; + -webkit-transition: @value; + -moz-transition: @value; + transition: @value; +} + +@indicator-size: unit(12 / 16 / 0.8, em); +@icon-size: unit(24 / 16 / 0.8, em); +@quick-ease: 100ms ease; +@progressive: #347bff; diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js new file mode 100644 index 0000000000..8d4be8c523 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js @@ -0,0 +1,2 @@ +// Create the namespace object +mediaWiki.widgets.datetime = {}; diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js index 59f1d507db..dffcbdda3a 100644 --- a/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js +++ b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js @@ -134,7 +134,7 @@ CSP.getNewMenuItems = function ( input ) { var i, promises = [], - deferred = new $.Deferred(); + deferred = $.Deferred(); if ( $.trim( input ) === '' ) { deferred.resolve( [] ); @@ -150,22 +150,30 @@ this.pushPending(); $.when.apply( $, promises ).done( function () { - var categories, categoryNames, + var categoryNames, allData = [], dataSets = Array.prototype.slice.apply( arguments ); // Collect values from all results allData = allData.concat.apply( allData, dataSets ); - // Remove duplicates - categories = allData.filter( function ( value, index, self ) { - return self.indexOf( value ) === index; - } ); - - // Get titles - categoryNames = categories.map( function ( name ) { - return mw.Title.newFromText( name, NS_CATEGORY ).getMainText(); - } ); + categoryNames = allData + // Remove duplicates + .filter( function ( value, index, self ) { + return self.indexOf( value ) === index; + } ) + // Get Title objects + .map( function ( name ) { + return mw.Title.newFromText( name ); + } ) + // Keep only titles from 'Category' namespace + .filter( function ( title ) { + return title && title.getNamespaceId() === NS_CATEGORY; + } ) + // Convert back to strings, strip 'Category:' prefix + .map( function ( title ) { + return title.getMainText(); + } ); deferred.resolve( categoryNames ); @@ -180,10 +188,20 @@ CSP.createItemWidget = function ( data ) { return new mw.widgets.CategoryCapsuleItemWidget( { apiUrl: this.api.apiUrl || undefined, - title: mw.Title.newFromText( data, NS_CATEGORY ) + title: mw.Title.makeTitle( NS_CATEGORY, data ) } ); }; + /** + * @inheritdoc + */ + CSP.getItemFromData = function ( data ) { + // This is a bit of a hack... We have to canonicalize the data in the same way that + // #createItemWidget and CategoryCapsuleItemWidget will do, otherwise we won't find duplicates. + data = mw.Title.makeTitle( NS_CATEGORY, data ).getMainText(); + return OO.ui.mixin.GroupElement.prototype.getItemFromData.call( this, data ); + }; + /** * Validates the values in `this.searchType`. * @@ -242,7 +260,7 @@ * @return {jQuery.Promise} Resolves with an array of categories */ CSP.searchCategories = function ( input, searchType ) { - var deferred = new $.Deferred(); + var deferred = $.Deferred(); switch ( searchType ) { case CategorySelector.SearchType.OpenSearch: diff --git a/resources/src/mediawiki/bookletlayout/option2/ccbysa.svg b/resources/src/mediawiki/bookletlayout/option2/ccbysa.svg index 13eb85fa11..605af75970 100644 --- a/resources/src/mediawiki/bookletlayout/option2/ccbysa.svg +++ b/resources/src/mediawiki/bookletlayout/option2/ccbysa.svg @@ -1,32 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option2/noderiv.svg b/resources/src/mediawiki/bookletlayout/option2/noderiv.svg index c3ee55be2c..96d80845ed 100644 --- a/resources/src/mediawiki/bookletlayout/option2/noderiv.svg +++ b/resources/src/mediawiki/bookletlayout/option2/noderiv.svg @@ -1,32 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option2/ownwork.svg b/resources/src/mediawiki/bookletlayout/option2/ownwork.svg index 639775f939..dc660c83ff 100644 --- a/resources/src/mediawiki/bookletlayout/option2/ownwork.svg +++ b/resources/src/mediawiki/bookletlayout/option2/ownwork.svg @@ -1,26 +1,26 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option2/useful.svg b/resources/src/mediawiki/bookletlayout/option2/useful.svg index baa982bf6c..2af3f938fa 100644 --- a/resources/src/mediawiki/bookletlayout/option2/useful.svg +++ b/resources/src/mediawiki/bookletlayout/option2/useful.svg @@ -1,24 +1,24 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/camera.svg b/resources/src/mediawiki/bookletlayout/option4/camera.svg index dc43b5defa..b1103968eb 100644 --- a/resources/src/mediawiki/bookletlayout/option4/camera.svg +++ b/resources/src/mediawiki/bookletlayout/option4/camera.svg @@ -1,60 +1,60 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/graphics.svg b/resources/src/mediawiki/bookletlayout/option4/graphics.svg index ff3d6821b2..c32f79fb17 100644 --- a/resources/src/mediawiki/bookletlayout/option4/graphics.svg +++ b/resources/src/mediawiki/bookletlayout/option4/graphics.svg @@ -1,98 +1,98 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/search-ltr.svg b/resources/src/mediawiki/bookletlayout/option4/search-ltr.svg index 7bcf826362..67c4ef0890 100644 --- a/resources/src/mediawiki/bookletlayout/option4/search-ltr.svg +++ b/resources/src/mediawiki/bookletlayout/option4/search-ltr.svg @@ -1,92 +1,92 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/search-rtl.svg b/resources/src/mediawiki/bookletlayout/option4/search-rtl.svg index 408f9e694e..17c54d2f63 100644 --- a/resources/src/mediawiki/bookletlayout/option4/search-rtl.svg +++ b/resources/src/mediawiki/bookletlayout/option4/search-rtl.svg @@ -1,53 +1,53 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/website-ltr.svg b/resources/src/mediawiki/bookletlayout/option4/website-ltr.svg index c6bf6fef18..ed07c6135c 100644 --- a/resources/src/mediawiki/bookletlayout/option4/website-ltr.svg +++ b/resources/src/mediawiki/bookletlayout/option4/website-ltr.svg @@ -1,57 +1,57 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/bookletlayout/option4/website-rtl.svg b/resources/src/mediawiki/bookletlayout/option4/website-rtl.svg index 080045ab10..dd8b0f0719 100644 --- a/resources/src/mediawiki/bookletlayout/option4/website-rtl.svg +++ b/resources/src/mediawiki/bookletlayout/option4/website-rtl.svg @@ -1,69 +1,69 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js index aa2998bbd1..7331df9c1e 100644 --- a/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js +++ b/resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js @@ -43,14 +43,15 @@ mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ) .done( function () { // Point the CategorySelector to the right wiki - this.upload.apiPromise.done( function ( api ) { + this.upload.getApi().done( function ( api ) { // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything if ( api.apiUrl ) { // Can't reuse the same object, CategorySelector calls #abort on its mw.Api instance this.categoriesWidget.api = new mw.ForeignApi( api.apiUrl ); } + }.bind( this ) ).always( function () { deferred.resolve(); - }.bind( this ) ); + } ); }.bind( this ) ); return deferred.promise(); }; @@ -131,11 +132,11 @@ notOwnWorkLocal = mw.message( 'foreign-structured-upload-form-label-not-own-work-local-default' ); } - $ownWorkMessage = $( '

' ).html( ownWorkMessage.parse() ) + $ownWorkMessage = $( '

' ).append( ownWorkMessage.parseDom() ) .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' ); $notOwnWorkMessage = $( '

' ).append( - $( '

' ).html( notOwnWorkMessage.parse() ), - $( '

' ).html( notOwnWorkLocal.parse() ) + $( '

' ).append( notOwnWorkMessage.parseDom() ), + $( '

' ).append( notOwnWorkLocal.parseDom() ) ); $ownWorkMessage.add( $notOwnWorkMessage ).find( 'a' ) .attr( 'target', '_blank' ) @@ -183,6 +184,20 @@ this.selectFileWidget.on( 'change', onUploadFormChange.bind( this ) ); this.ownWorkCheckbox.on( 'change', onUploadFormChange.bind( this ) ); + this.selectFileWidget.on( 'change', function () { + var file = layout.getFile(); + + // Set the date to lastModified once we have the file + if ( layout.getDateFromLastModified( file ) !== undefined ) { + layout.dateWidget.setValue( layout.getDateFromLastModified( file ) ); + } + + // Check if we have EXIF data and set to that where available + layout.getDateFromExif( file ).done( function ( date ) { + layout.dateWidget.setValue( date ); + } ); + } ); + return this.uploadForm; }; @@ -467,16 +482,16 @@ multiline: true, autosize: true } ); - this.dateWidget = new mw.widgets.DateInputWidget( { - $overlay: this.$overlay, - required: true, - mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow - } ); this.categoriesWidget = new mw.widgets.CategorySelector( { // Can't be done here because we don't know the target wiki yet... done in #initialize. // api: new mw.ForeignApi( ... ), $overlay: this.$overlay } ); + this.dateWidget = new mw.widgets.DateInputWidget( { + $overlay: this.$overlay, + required: true, + mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow + } ); fieldset = new OO.ui.FieldsetLayout( { label: mw.msg( 'upload-form-label-infoform-title' ) @@ -484,11 +499,13 @@ fieldset.addItems( [ new OO.ui.FieldLayout( this.filenameWidget, { label: mw.msg( 'upload-form-label-infoform-name' ), - align: 'top' + align: 'top', + help: mw.msg( 'upload-form-label-infoform-name-tooltip' ) } ), new OO.ui.FieldLayout( this.descriptionWidget, { label: mw.msg( 'upload-form-label-infoform-description' ), - align: 'top' + align: 'top', + help: mw.msg( 'upload-form-label-infoform-description-tooltip' ) } ), new OO.ui.FieldLayout( this.categoriesWidget, { label: mw.msg( 'foreign-structured-upload-form-label-infoform-categories' ), @@ -540,6 +557,71 @@ return this.upload.getText(); }; + /** + * Get original date from EXIF data + * + * @param {Object} file + * @return {jQuery.Promise} Promise resolved with the EXIF date + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromExif = function ( file ) { + var fileReader, + deferred = $.Deferred(); + + if ( file && file.type === 'image/jpeg' ) { + fileReader = new FileReader(); + fileReader.onload = function () { + var fileStr, arr, i, metadata; + + if ( typeof fileReader.result === 'string' ) { + fileStr = fileReader.result; + } else { + // Array buffer; convert to binary string for the library. + arr = new Uint8Array( fileReader.result ); + fileStr = ''; + for ( i = 0; i < arr.byteLength; i++ ) { + fileStr += String.fromCharCode( arr[ i ] ); + } + } + + try { + metadata = mw.libs.jpegmeta( this.result, file.name ); + } catch ( e ) { + metadata = null; + } + + if ( metadata !== null && metadata.exif !== undefined && metadata.exif.DateTimeOriginal ) { + deferred.resolve( moment( metadata.exif.DateTimeOriginal, 'YYYY:MM:DD' ).format( 'YYYY-MM-DD' ) ); + } else { + deferred.reject(); + } + }; + + if ( 'readAsBinaryString' in fileReader ) { + fileReader.readAsBinaryString( file ); + } else if ( 'readAsArrayBuffer' in fileReader ) { + fileReader.readAsArrayBuffer( file ); + } else { + // We should never get here + deferred.reject(); + throw new Error( 'Cannot read thumbnail as binary string or array buffer.' ); + } + } + + return deferred.promise(); + }; + + /** + * Get last modified date from file + * + * @param {Object} file + * @return {Object} Last modified date from file + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromLastModified = function ( file ) { + if ( file && file.lastModified ) { + return moment( file.lastModified ).format( 'YYYY-MM-DD' ); + } + }; + /* Setters */ /** diff --git a/resources/src/mediawiki/mediawiki.ForeignUpload.js b/resources/src/mediawiki/mediawiki.ForeignUpload.js index 61fb59f609..1a0b59a160 100644 --- a/resources/src/mediawiki/mediawiki.ForeignUpload.js +++ b/resources/src/mediawiki/mediawiki.ForeignUpload.js @@ -46,11 +46,12 @@ if ( this.target === 'local' ) { // If local uploads were requested, but they are disabled, fail. if ( !mw.config.get( 'wgEnableUploads' ) ) { - throw new Error( 'Local uploads are disabled' ); + this.apiPromise = $.Deferred().reject( 'uploaddisabledtext' ); + } else { + // We'll ignore the CORS and centralauth stuff if the target is + // the local wiki. + this.apiPromise = $.Deferred().resolve( new mw.Api( apiconfig ) ); } - // We'll ignore the CORS and centralauth stuff if the target is - // the local wiki. - this.apiPromise = $.Deferred().resolve( new mw.Api( apiconfig ) ); } else { api = new mw.Api(); this.apiPromise = api.get( { @@ -76,7 +77,7 @@ } } - throw new Error( 'Can not upload to requested foreign repo' ); + return $.Deferred().reject( 'upload-foreign-cant-upload' ); } ); } @@ -109,6 +110,13 @@ * or to local uploads if no foreign target is configured. */ + /** + * @inheritdoc + */ + ForeignUpload.prototype.getApi = function () { + return this.apiPromise; + }; + /** * Override from mw.Upload to make sure the API info is found and allowed */ diff --git a/resources/src/mediawiki/mediawiki.Title.js b/resources/src/mediawiki/mediawiki.Title.js index 47250eea0a..ec2a4b1e4c 100644 --- a/resources/src/mediawiki/mediawiki.Title.js +++ b/resources/src/mediawiki/mediawiki.Title.js @@ -6,6 +6,7 @@ ( function ( mw, $ ) { /*jshint latedef:false */ + // jscs:disable jsDoc /** * @class mw.Title * @@ -14,6 +15,23 @@ * logic directly and get null for invalid titles which is easier to work with. * * @constructor + * + * Note that in the constructor amd #newFromText method, `namespace` is the **default** namespace + * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior, + * use #makeTitle. Compare: + * + * new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText(); // => 'Template:Foo' + * + * new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo' + * mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText(); // => 'Template:Category:Foo' + * + * new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText(); // => 'Template:Template:Foo' + * * @param {string} title Title of the page. If no second argument given, * this will be searched for a namespace * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title @@ -32,6 +50,7 @@ return this; } + // jscs:enable jsDoc /* Private members */ @@ -116,6 +135,18 @@ return id; }, + /** + * @private + * @method getNamespacePrefix_ + * @param {number} namespace + * @return {string} + */ + getNamespacePrefix = function ( namespace ) { + return namespace === NS_MAIN ? + '' : + ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' ); + }, + rUnderscoreTrim = /^_+|_+$/g, rSplit = /^(.+?)_*:_*(.*)$/, @@ -212,7 +243,7 @@ ], /** - * Internal helper for #constructor and #newFromtext. + * Internal helper for #constructor and #newFromText. * * Based on Title.php#secureAndSplit * @@ -452,6 +483,10 @@ /** * Constructor for Title objects with a null return instead of an exception for invalid titles. * + * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace + * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for + * details. + * * @static * @param {string} title * @param {number} [namespace=NS_MAIN] Default namespace @@ -472,6 +507,24 @@ return t; }; + /** + * Constructor for Title objects with predefined namespace. + * + * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be + * overridden by a namespace prefix in `title`. See #constructor for details about this behavior. + * + * The single exception to this is when `namespace` is 0, indicating the main namespace. The + * function behaves like #newFromText in that case. + * + * @static + * @param {number} namespace Namespace to use for the title + * @param {string} title + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.makeTitle = function ( namespace, title ) { + return mw.Title.newFromText( getNamespacePrefix( namespace ) + title ); + }; + /** * Constructor for Title objects from user input altering that input to * produce a title that MediaWiki will accept as legal @@ -761,9 +814,7 @@ * @return {string} */ getNamespacePrefix: function () { - return this.namespace === NS_MAIN ? - '' : - ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' ); + return getNamespacePrefix( this.namespace ); }, /** @@ -906,7 +957,12 @@ * @return {string} */ getUrl: function ( params ) { - return mw.util.getUrl( this.toString(), params ); + var fragment = this.getFragment(); + if ( fragment ) { + return mw.util.getUrl( this.toString() + '#' + this.getFragment(), params ); + } else { + return mw.util.getUrl( this.toString(), params ); + } }, /** diff --git a/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js b/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js index 2ef3e874d3..4038228cd7 100644 --- a/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js +++ b/resources/src/mediawiki/mediawiki.Upload.BookletLayout.js @@ -152,7 +152,6 @@ */ mw.Upload.BookletLayout.prototype.initialize = function () { var - apiPromise, booklet = this, deferred = $.Deferred(); @@ -160,8 +159,7 @@ this.upload = this.createUpload(); this.setPage( 'upload' ); - apiPromise = this.upload.apiPromise || $.Deferred().resolve( this.upload.api ); - apiPromise.done( function ( api ) { + this.upload.getApi().done( function ( api ) { // If the user can't upload anything, don't give them the option to. api.getUserInfo().done( function ( userInfo ) { if ( userInfo.rights.indexOf( 'upload' ) === -1 ) { @@ -171,6 +169,9 @@ } ).always( function () { deferred.resolve(); } ); + } ).fail( function ( errorMsg ) { + booklet.getPage( 'upload' ).$element.msg( errorMsg ); + deferred.resolve(); } ); return deferred.promise(); @@ -202,7 +203,8 @@ layout = this, file = this.getFile(); - this.filenameWidget.setValue( file.name ); + this.setFilename( file.name ); + this.setPage( 'info' ); if ( this.shouldRecordBucket ) { @@ -210,8 +212,8 @@ } this.upload.setFile( file ); - // Explicitly set the filename so that the old filename isn't used in case of retry - this.upload.setFilenameFromFile(); + // The original file name might contain invalid characters, so use our sanitized one + this.upload.setFilename( this.getFilename() ); this.uploadPromise = this.upload.uploadToStash(); this.uploadPromise.then( function () { @@ -299,10 +301,8 @@ if ( error.info === 'TitleBlacklist prevents this title from being created' ) { // HACK Apparently the only reliable way to determine whether TitleBlacklist was involved return new OO.ui.Error( - $( '

' ).html( - // HACK TitleBlacklist doesn't have a sensible message, this one is from UploadWizard - mw.message( 'api-error-blacklisted' ).parse() - ), + // HACK TitleBlacklist doesn't have a sensible message, this one is from UploadWizard + $( '

' ).msg( 'api-error-blacklisted' ), { recoverable: false } ); } @@ -312,9 +312,7 @@ message = mw.message( 'api-error-unknownerror', JSON.stringify( stateDetails ) ); } return new OO.ui.Error( - $( '

' ).html( - message.parse() - ), + $( '

' ).append( message.parseDom() ), { recoverable: false } ); } @@ -361,16 +359,14 @@ } else if ( warnings.badfilename !== undefined ) { // Change the name if the current name isn't acceptable // TODO This might not really be the best place to do this - this.filenameWidget.setValue( warnings.badfilename ); + this.setFilename( warnings.badfilename ); return new OO.ui.Error( $( '

' ).msg( 'badfilename', warnings.badfilename ) ); } else { return new OO.ui.Error( - $( '

' ).html( - // Let's get all the help we can if we can't pin point the error - mw.message( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ).parse() - ), + // Let's get all the help we can if we can't pin point the error + $( '

' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ), { recoverable: false } ); } @@ -441,11 +437,13 @@ fieldset.addItems( [ new OO.ui.FieldLayout( this.filenameWidget, { label: mw.msg( 'upload-form-label-infoform-name' ), - align: 'top' + align: 'top', + help: mw.msg( 'upload-form-label-infoform-name-tooltip' ) } ), new OO.ui.FieldLayout( this.descriptionWidget, { label: mw.msg( 'upload-form-label-infoform-description' ), - align: 'top' + align: 'top', + help: mw.msg( 'upload-form-label-infoform-description-tooltip' ) } ) ] ); this.infoForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); @@ -520,7 +518,30 @@ * @return {string} */ mw.Upload.BookletLayout.prototype.getFilename = function () { - return this.filenameWidget.getValue(); + var filename = this.filenameWidget.getValue(); + if ( this.filenameExtension ) { + filename += '.' + this.filenameExtension; + } + return filename; + }; + + /** + * Prefills the {@link #infoForm information form} with the given filename. + * + * @protected + * @param {string} filename + */ + mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) { + var title = mw.Title.newFromFileName( filename ); + + if ( title ) { + this.filenameWidget.setValue( title.getNameText() ); + this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() ); + } else { + // Seems to happen for files with no extension, which should fail some checks anyway... + this.filenameWidget.setValue( filename ); + this.filenameExtension = null; + } }; /** diff --git a/resources/src/mediawiki/mediawiki.Upload.js b/resources/src/mediawiki/mediawiki.Upload.js index d80b4ebcc7..8a74ffc6ab 100644 --- a/resources/src/mediawiki/mediawiki.Upload.js +++ b/resources/src/mediawiki/mediawiki.Upload.js @@ -62,6 +62,17 @@ UP = Upload.prototype; + /** + * Get the mw.Api instance used by this Upload object. + * + * @return {jQuery.Promise} + * @return {Function} return.done + * @return {mw.Api} return.done.api + */ + UP.getApi = function () { + return $.Deferred().resolve( this.api ).promise(); + }; + /** * Set the text of the file page, to be created on file upload. * diff --git a/resources/src/mediawiki/mediawiki.checkboxtoggle.css b/resources/src/mediawiki/mediawiki.checkboxtoggle.css new file mode 100644 index 0000000000..3da0d438ce --- /dev/null +++ b/resources/src/mediawiki/mediawiki.checkboxtoggle.css @@ -0,0 +1,3 @@ +.client-nojs .mw-checkbox-toggle-controls { + display: none; +} diff --git a/resources/src/mediawiki/mediawiki.checkboxtoggle.js b/resources/src/mediawiki/mediawiki.checkboxtoggle.js new file mode 100644 index 0000000000..4be4a8da0d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.checkboxtoggle.js @@ -0,0 +1,37 @@ +/*! + * Allows users to perform all / none / invert operations on a list of + * checkboxes on the page. + * + * @licence GNU GPL v2+ + * @author Luke Faraone + * + * Based on ext.nuke.js from https://www.mediawiki.org/wiki/Extension:Nuke by + * Jeroen De Dauw + */ + +( function ( mw, $ ) { + 'use strict'; + + var $checkboxes = $( 'li input[type=checkbox]' ); + + function selectAll( check ) { + $checkboxes.prop( 'checked', check ); + } + + $( '#checkbox-all' ).click( function ( e ) { + selectAll( true ); + e.preventDefault(); + } ); + $( '#checkbox-none' ).click( function ( e ) { + selectAll( false ); + e.preventDefault(); + } ); + $( '#checkbox-invert' ).click( function ( e ) { + $checkboxes.each( function () { + $( this ).prop( 'checked', !$( this ).is( ':checked' ) ); + } ); + e.preventDefault(); + } ); + +}( mediaWiki, jQuery ) ); + diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js index 7afb9d3043..97a94b8fc1 100644 --- a/resources/src/mediawiki/mediawiki.feedback.js +++ b/resources/src/mediawiki/mediawiki.feedback.js @@ -254,6 +254,7 @@ classes: [ 'mw-feedbackDialog-welcome-message' ] } ); this.feedbackSubjectInput = new OO.ui.TextInputWidget( { + indicator: 'required', multiline: false } ); this.feedbackMessageInput = new OO.ui.TextInputWidget( { @@ -308,10 +309,7 @@ !this.useragentMandatory || this.useragentCheckbox.isSelected() ) && - ( - !!this.feedbackMessageInput.getValue() || - !!this.feedbackSubjectInput.getValue() - ) + this.feedbackSubjectInput.getValue() ); this.actions.setAbilities( { submit: isValid } ); diff --git a/resources/src/mediawiki/mediawiki.hlist.js b/resources/src/mediawiki/mediawiki.hlist.js deleted file mode 100644 index 8ba57f6f4b..0000000000 --- a/resources/src/mediawiki/mediawiki.hlist.js +++ /dev/null @@ -1,15 +0,0 @@ -/*! - * .hlist fallbacks for IE 8. - * @author [[User:Edokter]] - */ -( function ( mw, $ ) { - var profile = $.client.profile(); - - if ( profile.name === 'msie' && profile.versionNumber === 8 ) { - /* Add pseudo-selector class to last-child list items */ - mw.hook( 'wikipage.content' ).add( function ( $content ) { - $content.find( '.hlist' ).find( 'dd:last-child, dt:last-child, li:last-child' ) - .addClass( 'hlist-last-child' ); - } ); - } -}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.htmlform.ooui.css b/resources/src/mediawiki/mediawiki.htmlform.ooui.css index 309eb3493f..260fd37e3a 100644 --- a/resources/src/mediawiki/mediawiki.htmlform.ooui.css +++ b/resources/src/mediawiki/mediawiki.htmlform.ooui.css @@ -1,7 +1,6 @@ /* OOUIHTMLForm styles */ .mw-htmlform-ooui-wrapper { - width: 50em; margin: 1em 0; } @@ -29,3 +28,7 @@ margin-right: 5%; width: 39%; } + +.oo-ui-fieldLayout .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline { + margin-bottom: 0; +} diff --git a/resources/src/mediawiki/mediawiki.inspect.js b/resources/src/mediawiki/mediawiki.inspect.js index 514a3dd15e..671f38fa17 100644 --- a/resources/src/mediawiki/mediawiki.inspect.js +++ b/resources/src/mediawiki/mediawiki.inspect.js @@ -20,7 +20,7 @@ function humanSize( bytes ) { if ( !$.isNumeric( bytes ) || bytes === 0 ) { return bytes; } var i = 0, - units = [ '', ' kB', ' MB', ' GB', ' TB', ' PB' ]; + units = [ '', ' KiB', ' MiB', ' GiB', ' TiB', ' PiB' ]; for ( ; bytes >= 1024; bytes /= 1024 ) { i++; } // Maintain one decimal for kB and above, but don't @@ -107,15 +107,11 @@ */ auditSelectors: function ( css ) { var selectors = { total: 0, matched: 0 }, - style = document.createElement( 'style' ), - sheet, rules; + style = document.createElement( 'style' ); style.textContent = css; document.body.appendChild( style ); - // Standards-compliant browsers use .sheet.cssRules, IE8 uses .styleSheet.rules… - sheet = style.sheet || style.styleSheet; - rules = sheet.cssRules || sheet.rules; - $.each( rules, function ( index, rule ) { + $.each( style.sheet.cssRules, function ( index, rule ) { selectors.total++; // document.querySelector() on prefixed pseudo-elements can throw exceptions // in Firefox and Safari. Ignore these exceptions. diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js index c25e32767e..b04e01c2a2 100644 --- a/resources/src/mediawiki/mediawiki.js +++ b/resources/src/mediawiki/mediawiki.js @@ -632,27 +632,21 @@ obj[ key ] = val; } : function ( obj, key, val, msg ) { msg = 'Use of "' + key + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' ); - // Support: IE8 - // Can throw on Object.defineProperty. - try { - Object.defineProperty( obj, key, { - configurable: true, - enumerable: true, - get: function () { - mw.track( 'mw.deprecate', key ); - mw.log.warn( msg ); - return val; - }, - set: function ( newVal ) { - mw.track( 'mw.deprecate', key ); - mw.log.warn( msg ); - val = newVal; - } - } ); - } catch ( err ) { - // Fallback to creating a copy of the value to the object. - obj[ key ] = val; - } + Object.defineProperty( obj, key, { + configurable: true, + enumerable: true, + get: function () { + mw.track( 'mw.deprecate', key ); + mw.log.warn( msg ); + return val; + }, + set: function ( newVal ) { + mw.track( 'mw.deprecate', key ); + mw.log.warn( msg ); + val = newVal; + } + } ); + }; return log; @@ -2042,9 +2036,10 @@ // Whether the store is in use on this page. enabled: null, - // Modules whose string representation exceeds 100 kB are ineligible - // for storage due to bug T66721. - MODULE_SIZE_MAX: 100000, + // Modules whose string representation exceeds 100 kB (30 kB on FF) are + // ineligible for storage due to bug T66721. The quota is stricter on + // Firefox due to . + MODULE_SIZE_MAX: ( /Firefox/.test( navigator.userAgent ) ? 30 : 100 ) * 1000, // The contents of the store, mapping '[module name]@[version]' keys // to module implementations. diff --git a/resources/src/mediawiki/mediawiki.template.mustache.js b/resources/src/mediawiki/mediawiki.template.mustache.js index 624986a9bb..7f62256adf 100644 --- a/resources/src/mediawiki/mediawiki.template.mustache.js +++ b/resources/src/mediawiki/mediawiki.template.mustache.js @@ -4,8 +4,27 @@ mw.template.registerCompiler( 'mustache', { compile: function ( src ) { return { - render: function ( data ) { - return $( $.parseHTML( Mustache.render( src, data ) ) ); + /** + * @ignore + * @return {string} The raw source code of the template + */ + getSource: function () { + return src; + }, + /** + * @ignore + * @param {Object} data Data to render + * @param {Object} partialTemplates Map partial names to Mustache template objects + * returned by mw.template.get() + */ + render: function ( data, partialTemplates ) { + var partials = {}; + if ( partialTemplates ) { + $.each( partialTemplates, function ( name, template ) { + partials[ name ] = template.getSource(); + } ); + } + return $( $.parseHTML( Mustache.render( src, data, partials ) ) ); } }; } diff --git a/resources/src/mediawiki/page/ready.js b/resources/src/mediawiki/page/ready.js index 9505bdd1e0..9b3458bdfc 100644 --- a/resources/src/mediawiki/page/ready.js +++ b/resources/src/mediawiki/page/ready.js @@ -36,7 +36,7 @@ // Things outside the wikipage content $( function () { - var $nodes; + var $nodes, $oouiNodes; if ( !supportsPlaceholder ) { // Exclude content to avoid hitting it twice for the (first) wikipage content @@ -60,16 +60,35 @@ $nodes.updateTooltipAccessKeys(); // Infuse OOUI widgets, if any are present - $nodes = $( '[data-ooui]' ); - if ( $nodes.length ) { + $oouiNodes = $( '[data-ooui]' ); + if ( $oouiNodes.length ) { // FIXME: We should only load the widgets that are being infused mw.loader.using( [ 'mediawiki.widgets', 'mediawiki.widgets.UserInputWidget' ] ).done( function () { - $nodes.each( function () { + $oouiNodes.each( function () { OO.ui.infuse( this ); } ); } ); } + $nodes = $( '.catlinks[data-mw="interface"]' ); + if ( $nodes.length ) { + /** + * Fired when categories are being added to the DOM + * + * It is encouraged to fire it before the main DOM is changed (when $content + * is still detached). However, this order is not defined either way, so you + * should only rely on $content itself. + * + * This includes the ready event on a page load (including post-edit loads) + * and when content has been previewed with LivePreview. + * + * @event wikipage_categories + * @member mw.hook + * @param {jQuery} $content The most appropriate element containing the content, + * such as .catlinks + */ + mw.hook( 'wikipage.categories' ).fire( $nodes ); + } } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/startup.js b/resources/src/startup.js index 1a10f8379e..0f51a35332 100644 --- a/resources/src/startup.js +++ b/resources/src/startup.js @@ -31,8 +31,8 @@ function isCompatible( ua ) { // Browsers with outdated or limited JavaScript engines get the no-JS experience return !( - // Internet Explorer < 8 - ( ua.indexOf( 'MSIE' ) !== -1 && parseFloat( ua.split( 'MSIE' )[ 1 ] ) < 8 ) || + // Internet Explorer < 9 + ( ua.indexOf( 'MSIE' ) !== -1 && parseFloat( ua.split( 'MSIE' )[ 1 ] ) < 9 ) || // Firefox < 3 ( ua.indexOf( 'Firefox/' ) !== -1 && parseFloat( ua.split( 'Firefox/' )[ 1 ] ) < 3 ) || // Opera < 12 diff --git a/tests/TestsAutoLoader.php b/tests/TestsAutoLoader.php index 1c4851544d..05f454cc37 100644 --- a/tests/TestsAutoLoader.php +++ b/tests/TestsAutoLoader.php @@ -48,7 +48,9 @@ $wgAutoloadClasses += array( 'LessFileCompilationTest' => "$testDir/phpunit/LessFileCompilationTest.php", # tests/phpunit/includes + 'RevisionStorageTest' => "$testDir/phpunit/includes/RevisionStorageTest.php", 'TestingAccessWrapper' => "$testDir/phpunit/includes/TestingAccessWrapper.php", + 'TestLogger' => "$testDir/phpunit/includes/TestLogger.php", # tests/phpunit/includes/api 'ApiFormatTestBase' => "$testDir/phpunit/includes/api/format/ApiFormatTestBase.php", @@ -85,6 +87,9 @@ $wgAutoloadClasses += array( # tests/phpunit/includes/logging 'LogFormatterTestCase' => "$testDir/phpunit/includes/logging/LogFormatterTestCase.php", + # tests/phpunit/includes/page + 'WikiPageTest' => "$testDir/phpunit/includes/page/WikiPageTest.php", + # tests/phpunit/includes/password 'PasswordTestCase' => "$testDir/phpunit/includes/password/PasswordTestCase.php", @@ -94,6 +99,10 @@ $wgAutoloadClasses += array( 'ResourceLoaderImageModuleTestable' => "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php", + # tests/phpunit/includes/session + 'MediaWiki\\Session\\TestBagOStuff' => "$testDir/phpunit/includes/session/TestBagOStuff.php", + 'MediaWiki\\Session\\TestUtils' => "$testDir/phpunit/includes/session/TestUtils.php", + # tests/phpunit/includes/specials 'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php", @@ -118,6 +127,9 @@ $wgAutoloadClasses += array( 'MockSvgHandler' => "$testDir/phpunit/mocks/media/MockSvgHandler.php", 'MockDjVuHandler' => "$testDir/phpunit/mocks/media/MockDjVuHandler.php", 'MockWebRequest' => "$testDir/phpunit/mocks/MockWebRequest.php", + 'MediaWiki\\Session\\DummySessionBackend' + => "$testDir/phpunit/mocks/session/DummySessionBackend.php", + 'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php", # tests/parser 'NewParserTest' => "$testDir/phpunit/includes/parser/NewParserTest.php", diff --git a/tests/parser/parserTest.inc b/tests/parser/parserTest.inc index b91a5bc8b6..6a95205102 100644 --- a/tests/parser/parserTest.inc +++ b/tests/parser/parserTest.inc @@ -512,6 +512,7 @@ class ParserTest { $ok = true; foreach ( $filenames as $filename ) { + echo "Running parser tests from: $filename\n"; $tests = new TestFileIterator( $filename, $this ); $ok = $this->runTests( $tests ) && $ok; } @@ -962,7 +963,7 @@ class ParserTest { 'protected_titles', 'revision', 'text', 'pagelinks', 'imagelinks', 'categorylinks', 'templatelinks', 'externallinks', 'langlinks', 'iwlinks', 'site_stats', 'ipblocks', 'image', 'oldimage', - 'recentchanges', 'watchlist', 'interwiki', 'logging', + 'recentchanges', 'watchlist', 'interwiki', 'logging', 'log_search', 'querycache', 'objectcache', 'job', 'l10n_cache', 'redirect', 'querycachetwo', 'archive', 'user_groups', 'page_props', 'category' ); @@ -1099,6 +1100,19 @@ class ParserTest { 'fileExists' => true ), $this->db->timestamp( '20010115123500' ), $user ); + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Video.ogv' ) ); + $image->recordUpload2( '', 'A pretty movie', 'Will it play', array( + 'size' => 12345, + 'width' => 320, + 'height' => 240, + 'bits' => 0, + 'media_type' => MEDIATYPE_VIDEO, + 'mime' => 'application/ogg', + 'metadata' => serialize( array() ), + 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20010115123500' ), $user ); + # A DjVu file $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) ); $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', array( @@ -1212,6 +1226,8 @@ class ParserTest { ' version="1.1" width="240" height="180"/>' ); wfMkdirParents( $dir . '/5/5f', null, __METHOD__ ); copy( "$IP/tests/phpunit/data/parser/LoremIpsum.djvu", "$dir/5/5f/LoremIpsum.djvu" ); + wfMkdirParents( $dir . '/0/00', null, __METHOD__ ); + copy( "$IP/tests/phpunit/data/parser/320x240.ogv", "$dir/0/00/Video.ogv" ); return; } @@ -1253,6 +1269,14 @@ class ParserTest { "$dir/f/ff/Foobar.svg", "$dir/thumb/f/ff/Foobar.svg/*-Foobar.svg.png", "$dir/math/f/a/5/fa50b8b616463173474302ca3e63586b.png", + "$dir/0/00/Video.ogv", + "$dir/thumb/0/00/Video.ogv/120px--Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/180px--Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/240px--Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/320px--Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/270px--Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/320px-seek=2-Video.ogv.jpg", + "$dir/thumb/0/00/Video.ogv/320px-seek=3.3666666666667-Video.ogv.jpg", ) ); @@ -1270,10 +1294,14 @@ class ParserTest { "$dir/thumb/f/ff/Foobar.svg", "$dir/thumb/f/ff/", "$dir/thumb/f/", + "$dir/0/00/", "$dir/0/09/", "$dir/0/", "$dir/5/5f", "$dir/5", + "$dir/thumb/0/00/Video.ogv", + "$dir/thumb/0/00", + "$dir/thumb/0", "$dir/thumb/5/5f/LoremIpsum.djvu", "$dir/thumb/5/5f", "$dir/thumb/5", diff --git a/tests/parser/parserTests.txt b/tests/parser/parserTests.txt index a0e0b3a220..438fe314ca 100644 --- a/tests/parser/parserTests.txt +++ b/tests/parser/parserTests.txt @@ -14,6 +14,7 @@ # Plus any combination of these: # # cat add category links +# (ignored by Parsoid, since it emits s) # ill add inter-language links # (ignored by Parsoid, since it emits s) # subpage enable subpages (disabled by default) @@ -1335,6 +1336,8 @@ array ( ) +!! html/parsoid +


 !! end
 
 !! test
@@ -2517,7 +2520,6 @@ Barack Obama  of the United States
 

!! end -## PHP parser discards the "
 !! html/php
 
x
-
+<pre
+!! html/php+tidy +
+x
+
+

<pre

!! html/parsoid
x
@@ -3329,14 +3336,18 @@ parsoid=wt2html,wt2wt !! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. !! test 7b. Indent-pre and category links !! options -parsoid=wt2html,wt2wt +parsoid=wt2html !! wikitext [[Category:foo]] a [[Category:foo]] {{echo|b}} -!! html +!! html/parsoid
 a
   b
!! end @@ -7119,6 +7130,17 @@ Piped link with multiple pipe characters in link text

|The|Main|Page|

!! end +!! test +Piped link with no link text +!! wikitext +[[Thomas Bek (bishop of St David's)|]] +!! html/php +

[[Thomas Bek (bishop of St David's)|]] +

+!! html/parsoid +

[[Thomas Bek (bishop of St David's)|]]

+!! end + !! test Broken link !! wikitext @@ -9651,7 +9673,7 @@ Magic Word: {{NUMBEROFFILES}} !! wikitext {{NUMBEROFFILES}} !! html -

5 +

6

!! end @@ -10858,10 +10880,18 @@ Un-closed !! wikitext !! html +

<includeonly> +

!! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize the include directives to serialize on their own line. +## Selser will take care of preserving formatting in scenarios where they +## intermingled with other wikitext. !! test Includes and comments at SOL +!! options +parsoid=wt2html,html2html !! wikitext == hu == @@ -11042,10 +11072,14 @@ parsoid=wt2html,wt2wt
!!end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize the include directives to serialize on their own line. +## Selser will take care of preserving formatting in scenarios where they +## intermingled with other wikitext. !!test 2. Table tag in SOL posn. should get reparsed correctly with valid TSR !!options -parsoid=wt2html,wt2wt +parsoid=wt2html !!wikitext a{| {{{b}}} |c @@ -14245,7 +14279,7 @@ cat pst !! wikitext [[Category:MediaWiki User's Guide|]] -!! html +!! html/php [[Category:MediaWiki User's Guide|MediaWiki User's Guide]] !! end @@ -14256,19 +14290,26 @@ cat pst !! wikitext [[Category:Foo (bar)|]] -!! html +!! html/php [[Category:Foo (bar)|Foo]] !! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. !! test Category with link tail !! options cat pst +parsoid=wt2html !! wikitext 123[[Category:Foo]]456 -!! html +!! html/php 123[[Category:Foo]]456 +!! html/parsoid +

123456

!! end !! test @@ -14278,7 +14319,7 @@ cat pst !! wikitext [[Category:{{echo|Foo}}]] -!! html +!! html/php [[Category:{{echo|Foo}}]] !! end @@ -14289,7 +14330,7 @@ cat pst !! wikitext [[Category:Foo|{{echo|Bar}}]] -!! html +!! html/php [[Category:Foo|{{echo|Bar}}]] !! end @@ -14300,12 +14341,18 @@ cat pst !! wikitext [[Category:{{echo|Foo}}|{{echo|Bar}}]] -!! html +!! html/php [[Category:{{echo|Foo}}|{{echo|Bar}}]] !! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. !! test Category / paragraph interactions +!! options +parsoid=wt2html !! wikitext Foo [[Category:Baz]] Bar @@ -14332,7 +14379,7 @@ Bar [[Category:Baz]] {{echo|[[Category:Baz]]}} [[Category:Baz]] -!! html +!! html/php

Foo Bar

Foo Bar @@ -14342,20 +14389,32 @@ Bar

Foo Bar

+!! html/parsoid +

Foo Bar

+

Foo Bar

+

Foo Bar

+

Foo Bar

+

Foo Bar

+ !! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. +## ## The whitespace on the empty line is part of the test. Please do not delete !! test 1. Categories and newlines: All preceding newlines should be suppressed (courtesy bug 87) !! options -parsoid=wt2html,wt2wt +parsoid=wt2html !! wikitext This [[Category:Foo]] and this should be part of same paragraph (not an indent-pre) {{echo|[[Category:Foo]] and so should this!}} -!! html +!! html/php

This and this should be part of same paragraph (not an indent-pre) and so should this!

!! html/parsoid @@ -14453,8 +14512,14 @@ parsoid=wt2html !! end +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. !! test 6. Categories and newlines: migrateTrailingCategories dom pass should not migrate categories not preceded by newlines +!! options +parsoid=wt2html !! wikitext * a [[Category:Foo]] !! html/parsoid @@ -14505,13 +14570,20 @@ parsoid

!! end -# html2wt localizes the "Category" namespace. -# XXX the element needs an empty data-parsoid attribute, or -# else the html2html test fails because spaces are inserted. +# We used to, but no longer wt2wt this test since the default serializer +# will normalize all categories to serialize on their own line. +# This wikitext usage is going to be fairly uncommon in production and +# selser will take care of preventing whitespace insertion if this +# occurs in an article. +# +# html2html disabled for the same reason (whitespace insertion between +# x and y). +# +# html2wt disabled because it localizes the "Category" namespace. !! test Link prefix/suffixes aren't applied to category links !! options -parsoid=wt2html,wt2wt,html2html +parsoid=wt2html language=is !! wikitext x[[Category:Foo]]y @@ -16152,10 +16224,15 @@ array ( ) +!! html/parsoid +

 !! end
 
+## Don't expect parsoid to rt this form.
 !! test
 Parser hook: empty input using terminated empty elements
+!! options
+parsoid=wt2html,html2html
 !! wikitext
 
 !! html/php
@@ -16165,6 +16242,8 @@ array (
 )
 
 
+!! html/parsoid
+

 !! end
 
 !! test
@@ -16178,6 +16257,8 @@ array (
 )
 
 
+!! html/parsoid
+

 !! end
 
 !! test
@@ -16191,11 +16272,15 @@ array (
 )
 
 
+!! html/parsoid
+

 !! end
 
-
+## Don't expect parsoid to rt this form.
 !! test
 Parser hook: case insensitive
+!! options
+parsoid=wt2html,html2html
 !! wikitext
 input
 !! html/php
@@ -16205,11 +16290,15 @@ array (
 )
 
 
+!! html/parsoid
+

 !! end
 
-
+## Don't expect parsoid to rt this form.
 !! test
 Parser hook: case insensitive, redux
+!! options
+parsoid=wt2html,html2html
 !! wikitext
 input
 !! html/php
@@ -16219,6 +16308,8 @@ array (
 )
 
 
+!! html/parsoid
+

 !! end
 
 !! test
@@ -16234,11 +16325,35 @@ array (
 )
 </tag>
 
+!! html/parsoid
+
</tag>
 !! end
 
 !! test
 Parser hook: basic arguments
 !! wikitext
+
+!! html/php
+
+''
+array (
+  'width' => '200',
+  'height' => '100',
+  'depth' => '50',
+  'square' => '',
+)
+
+ +!! html/parsoid +

+!! end
+
+## Don't expect parsoid to rt this form.
+!! test
+Parser hook: basic arguments, variations
+!! options
+parsoid=wt2html,html2html
+!! wikitext
 
 !! html/php
 
@@ -16251,12 +16366,14 @@ array (
 )
 
+!! html/parsoid +

 !! end
 
 !! test
 Parser hook: argument containing a forward slash (bug 5344)
 !! wikitext
-
+
 !! html/php
 
 ''
@@ -16265,10 +16382,15 @@ array (
 )
 
+!! html/parsoid +

 !! end
 
+## Don't expect parsoid to rt this form.
 !! test
 Parser hook: empty input using terminated empty elements (bug 2374)
+!! options
+parsoid=wt2html,html2html
 !! wikitext
 text
 !! html/php
@@ -16279,6 +16401,8 @@ array (
 )
 text
 
+!! html/parsoid
+
text
 !! end
 
 # 
should be output literally since there is no matching tag that begins it @@ -16311,21 +16435,28 @@ array ( Parser hook: static parser hook not inside a comment !! wikitext hello, world - + + !! html/php -

hello, world +


+hello, world

+!! html/parsoid +

+

hello, world

!! end - !! test Parser hook: static parser hook inside a comment !! wikitext - + !! html/php


+!! html/parsoid + +

!! end # Nested template calls; this case was broken by Parser.php rev 1.506, @@ -19211,17 +19342,25 @@ Category:分類 blah !! endarticle +## We used to, but no longer wt2wt this test since the default serializer +## will normalize all categories to serialize on their own line. +## This wikitext usage is going to be fairly uncommon in production and +## selser will take care of preserving formatting in those scenarios. !! test Don't convert blue categorylinks to another variant (bug 33210) !! options -language=zh cat +cat +language=zh +parsoid=wt2html !! wikitext [[A]][[Category:分类]] -!! html +!! html/php 分类 +!! html/parsoid +

A

+ !! end - !! test Stripping -{}- tags (language variants) !! options @@ -19756,7 +19895,7 @@ Tildes in comments pst !! wikitext -!! html +!! html/php !! end @@ -20078,7 +20217,7 @@ Edit comment with link comment !! wikitext I like the [[Main Page]] a lot -!! html +!! html/php I like the Main Page a lot !!end @@ -20088,7 +20227,7 @@ Edit comment with link and link text comment !! wikitext I like the [[Main Page|best pages]] a lot -!! html +!! html/php I like the best pages a lot !!end @@ -20098,7 +20237,7 @@ Edit comment with link and link text with suffix comment !! wikitext I like the [[Main Page|best page]]s a lot -!! html +!! html/php I like the best pages a lot !!end @@ -20108,7 +20247,7 @@ Edit comment with section link (non-local, eg in history list) comment title=[[Main Page]] !! wikitext /* External links */ removed bogus entries -!! html +!! html/php →‎External links: removed bogus entries !!end @@ -20118,7 +20257,7 @@ Edit comment with section link and text before it (non-local, eg in history list comment title=[[Main Page]] !! wikitext pre-comment text /* External links */ removed bogus entries -!! html +!! html/php pre-comment text →‎External links: removed bogus entries !!end @@ -20128,7 +20267,7 @@ Edit comment with section link (local, eg in diff view) comment local title=[[Main Page]] !! wikitext /* External links */ removed bogus entries -!! html +!! html/php →‎External links: removed bogus entries !!end @@ -20140,7 +20279,7 @@ subpage title=[[Subpage test]] !! wikitext Poked at a [[/subpage]] here... -!! html +!! html/php Poked at a /subpage here... !!end @@ -20152,7 +20291,7 @@ subpage title=[[Subpage test]] !! wikitext Poked at a [[/subpage|neat little page]] here... -!! html +!! html/php Poked at a neat little page here... !!end @@ -20163,7 +20302,7 @@ comment title=[[Subpage test]] !! wikitext Poked at a [[/subpage]] here... -!! html +!! html/php Poked at a /subpage here... !!end @@ -20175,7 +20314,7 @@ local title=[[Main Page]] !! wikitext [[#section]] -!! html +!! html/php #section !! end @@ -20186,24 +20325,28 @@ comment title=[[Main Page]] !! wikitext [[#section]] -!! html +!! html/php #section !! end !! test Anchor starting with underscore +!! options +title=[[Foo]] !! wikitext [[#_ref|One]] -!! html +!! html/php

One

+!! html/parsoid +

One

!! end !! test Id starting with underscore !! wikitext
-!! html +!! html/*
!! end @@ -20215,7 +20358,7 @@ comment title=[[Main Page]] !! wikitext /* __hello__world__ */ -!! html +!! html/php →‎__hello__world__ !! end @@ -20534,17 +20677,20 @@ HTML5 data attributes !! wikitext Baz

Quuz

-!! html +!! html/php

Baz

Quuz

+!! html/parsoid +

Baz

+

Quuz

!! end !! test Strip reserved data attributes !! wikitext -
d
+
d
!! html
d
@@ -21386,14 +21532,12 @@ parsoid=wt2html,wt2wt !!test Ref: 1. ref-location should be replaced with an index span -!!options -parsoid !! wikitext A foo B foo C -!! html +!! html/parsoid

A [1] B [2] C [3]

@@ -21406,13 +21550,11 @@ C foo
B -!! html +!! html/parsoid

A [1] B [1]

    @@ -21422,14 +21564,12 @@ B foo B C -!! html +!! html/parsoid

    A [1] B [1] C [1]

    @@ -21441,12 +21581,10 @@ C foo
    -!! html +!! html/parsoid

    A [1]

    1. ↑ foo
    2. @@ -21455,15 +21593,13 @@ A foo !!test Ref: 5. body should accept generic wikitext -!!options -parsoid !! wikitext A This is a '''[[bolded link]]''' and this is a {{echo|transclusion}} -!! html +!! html/parsoid

      A [1]

        @@ -21474,8 +21610,6 @@ A !!test Ref: 6. indent-pres should not be output in ref-body -!!options -parsoid !! wikitext A foo @@ -21484,7 +21618,7 @@ A -!! html +!! html/parsoid

        A [1]

          @@ -21497,8 +21631,6 @@ A !!test Ref: 7. No p-wrapping in ref-body -!!options -parsoid !! wikitext A foo @@ -21514,7 +21646,7 @@ booz -!! html +!! html/parsoid

          A [1]

            @@ -21534,27 +21666,23 @@ booz !!test Ref: 8. transclusion wikitext has lower precedence -!!options -parsoid !! wikitext A foo {{echo| B C}} -!! html +!! html/parsoid

            A [1] B C}}

              -
            1. ↑ foo {{echo|
            2. +
            3. ↑ foo {{echo|
            !!end !!test Ref: 9. unclosed comments should not leak out of ref-body -!!options -parsoid !! wikitext A foo @@ -21563,13 +21691,11 @@ A foo [[Category:A3]] how goes it == +== [[Category:A3]] how goes it == -== it goes well [[Category:A4]] == +== it goes well [[Category:A4]] == -==howdy [[Category:A5]] == +==howdy [[Category:A5]]== == __TOC__ ok == !! end @@ -25620,7 +25760,7 @@ parsoid={ "modes": ["html2wt"], "suppressErrors": true } # shown to sneak through on occasion. See T101768. # The original wikitext here is: [http://test.com [[one]] two three] !! test -Strip span tags added to mark as misnested +Strip span tags added to mark misnested links !! options parsoid=html2wt !! html/parsoid @@ -25629,10 +25769,112 @@ parsoid=html2wt [http://test.com][[one]] two three !! end +!! test +Use data-parsoid.firstWikitextNode to compute newline constraints for template content +!! options +parsoid=html2wt +!! html/parsoid +a + +
            d +
            +!! wikitext +{{echo|a}} +{|{{echo|c +{{!}}d +}} +|} +!! end + +## This test verifies the presence and computation of this attribute indirectly +## by making an edit and ensuring that the serialization is correct (which it would be +## only if firstWikitextNode is properly set). +!! test +data-parsoid.firstWikitextNode should be computed properly in the presence of fostered content +!! options +parsoid= { + "modes": ["wt2wt"], + "changes": [ + [ "div#x", "remove" ], + [ "div", "before", "
            new
            " ] + ] +} +!! wikitext +
            foo
            +{| +{{echo|
            boo
            +{{!}}b}} +|c +|} +!! wikitext/edited + +
            new
            +{| +{{echo|
            boo
            +{{!}}b}} +|c +|} +!! end + # -------------------------------------------- # Tests spec'ing wikitext serialization norms | # -------------------------------------------- +!! test +1. Categories should always be serialized on their own line +!! options +parsoid=html2wt +!! html/parsoid +foobar +!! wikitext +foo +[[Category:Foo]] +bar +!! end + +!! test +2. Categories that are part of templates should not introduce a line break +!! wikitext +foo {{echo|bar [[Category:baz]]}} bar +!! html/parsoid +

            foo bar bar

            +!! end + +# Careful while editing these next 2 tests. There are \u200f characters +# before and after the tags in the HTML and following some +# of the categories in wikitext +# Do not remove these characters in edits. +# +# As part of the serialization, these bidi characters will get stripped. +!! test +RTL (\u200f) and LTR (\u200e) markers around category tags should be stripped +!! options +parsoid={ + "modes": ["html2wt"], + "scrubWikitext": true +} +!! html/parsoid +

            ‏‏ +‏‏

            +!! wikitext +[[קטגוריה:טקסים]] +[[קטגוריה: שיטות משפט]] +!! end + +!! test +RTL (\u200f) and LTR (\u200e) markers should not be stripped if followed by a text node +!! options +parsoid={ + "modes": ["html2wt"], + "scrubWikitext": true +} +!! html/parsoid +

            ‏‏y

            +!! wikitext +[[קטגוריה:טקסים]] +‏y +!! end + !! test Lists: Add space after bullets !! options @@ -25720,6 +25962,35 @@ parsoid={ !! wikitext/edited !! end +!! test +Headings: Replace
            with a single whitespace char (when scrubWikitext = true) +!! options +parsoid={ + "modes": ["html2wt"], + "scrubWikitext": true +} +!! html/parsoid +

            foo
            bar

            +

            foo
            bar
            baz

            +!! wikitext +== foo bar == + +== foo bar baz == +!! end + +!! test +Headings: Replace
            with a single whitespace char (when scrubWikitext = false) +!! options +parsoid={ + "modes": ["html2wt"], + "scrubWikitext": false +} +!! html/parsoid +

            foo
            bar

            +!! wikitext +== foo
            bar == +!! end + !! test 1. WT Quote Tags: suppress newly created empty style tags !! options @@ -26225,10 +26496,61 @@ parsoid=html2wt <nowiki>''foo''</nowiki> !! end +# This is meant to be an interim fix while we go about figuring out +# how to not introduce these trailing s in the first place. +!! test +T115717: Strip trailing s (without affecting valid uses) +!! options +parsoid=html2wt +!! html/parsoid +

            x +y

            +

            +

            +!! wikitext +x +y + +{{echo| +1 = }} + +{{echo| +1 = +}} +!! end + # --------------------------------------------------- # End of tests spec'ing wikitext serialization norms | # --------------------------------------------------- +# T104032 +!! test +Bare inline nodes not wrapped inside p-tags should be treated as p-wrapped +!! options +parsoid=html2wt +!! html/parsoid +a

            b

            +c

            d

            + + + +
            a

            b

            c

            d

            +!! wikitext +a + +b + +'''c''' + +d +{| +|a +b +|'''c''' +d +|} +!! end + # ----------------------------------------------------------------- # End of section for Parsoid-only html2wt tests for serialization # of new content diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index fc2f743e12..861e3bd864 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -221,6 +221,8 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { } protected function tearDown() { + global $wgRequest; + $status = ob_get_status(); if ( isset( $status['name'] ) && $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier' @@ -252,6 +254,12 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { $this->mwGlobals = array(); RequestContext::resetMain(); MediaHandler::resetCache(); + if ( session_id() !== '' ) { + session_write_close(); + session_id( '' ); + } + $wgRequest = new FauxRequest(); + MediaWiki\Session\SessionManager::resetCache(); $phpErrorLevel = intval( ini_get( 'error_reporting' ) ); @@ -509,6 +517,13 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { false, $user ); + + // doEditContent() probably started the session via + // User::loadFromSession(). Close it now. + if ( session_id() !== '' ) { + session_write_close(); + session_id( '' ); + } } } diff --git a/tests/phpunit/data/gitinfo/extension/gitinfo.json b/tests/phpunit/data/gitinfo/extension/gitinfo.json new file mode 100644 index 0000000000..8cf21bda7b --- /dev/null +++ b/tests/phpunit/data/gitinfo/extension/gitinfo.json @@ -0,0 +1,7 @@ +{ + "head": "refs/heads/master", + "headSHA1": "0123456789abcdef0123456789abcdef01234567", + "headCommitDate": "1070884800", + "branch": "master", + "remoteURL": "https://gerrit.wikimedia.org/r/mediawiki/core" +} diff --git a/tests/phpunit/data/parser/320x240.ogv b/tests/phpunit/data/parser/320x240.ogv new file mode 100644 index 0000000000..79038206ae Binary files /dev/null and b/tests/phpunit/data/parser/320x240.ogv differ diff --git a/tests/phpunit/data/templates/conds.mustache b/tests/phpunit/data/templates/conds.mustache new file mode 100644 index 0000000000..5ebd2ea3d9 --- /dev/null +++ b/tests/phpunit/data/templates/conds.mustache @@ -0,0 +1 @@ +{{#list}}oh no{{/list}}{{#foo}}none of this should render{{/foo}} \ No newline at end of file diff --git a/tests/phpunit/includes/ExportTest.php b/tests/phpunit/includes/ExportTest.php new file mode 100644 index 0000000000..202603030b --- /dev/null +++ b/tests/phpunit/includes/ExportTest.php @@ -0,0 +1,71 @@ + + */ +class ExportTest extends MediaWikiLangTestCase { + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + 'wgLanguageCode' => 'en', + 'wgCapitalLinks' => true, + ) ); + } + + /** + * @covers WikiExporter::pageByTitle + */ + public function testPageByTitle() { + global $wgContLang; + $pageTitle = 'UTPage'; + + $exporter = new WikiExporter( + $this->db, + WikiExporter::FULL + ); + + $title = Title::newFromText( $pageTitle ); + + ob_start(); + $exporter->openStream(); + $exporter->pageByTitle( $title ); + $exporter->closeStream(); + $xmlString = ob_get_clean(); + + // This throws error if invalid xml output + $xmlObject = simplexml_load_string( $xmlString ); + + /** + * Check namespaces match xml + * FIXME: PHP 5.3 support. When we don't support PHP 5.3, + * add ->namespace to object and remove from array + */ + $xmlNamespaces = (array) $xmlObject->siteinfo->namespaces; + $xmlNamespaces = str_replace( ' ', '_', $xmlNamespaces['namespace'] ); + unset ( $xmlNamespaces[ '@attributes' ] ); + foreach ( $xmlNamespaces as &$namespaceObject ) { + if ( is_object( $namespaceObject ) ) { + $namespaceObject = ''; + } + } + + $actualNamespaces = (array) $wgContLang->getNamespaces(); + $actualNamespaces = array_values( $actualNamespaces ); + $this->assertEquals( $actualNamespaces, $xmlNamespaces ); + + // Check xml page title correct + $xmlTitle = (array) $xmlObject->page->title; + $this->assertEquals( $pageTitle, $xmlTitle[0] ); + + // Check xml page text is not empty + $text = (array) $xmlObject->page->revision->text; + $this->assertNotEquals( '', $text[0] ); + } + +} diff --git a/tests/phpunit/includes/GitInfoTest.php b/tests/phpunit/includes/GitInfoTest.php index c3539d0e7d..9f4a01c9d8 100644 --- a/tests/phpunit/includes/GitInfoTest.php +++ b/tests/phpunit/includes/GitInfoTest.php @@ -9,18 +9,23 @@ class GitInfoTest extends MediaWikiTestCase { $this->setMwGlobals( 'wgGitInfoCacheDirectory', __DIR__ . '/../data/gitinfo' ); } - public function testValidJsonData() { - $dir = $GLOBALS['IP'] . DIRECTORY_SEPARATOR . 'testValidJsonData'; - $fixture = new GitInfo( $dir ); - - $this->assertTrue( $fixture->cacheIsComplete() ); - $this->assertEquals( 'refs/heads/master', $fixture->getHead() ); + protected function assertValidGitInfo( GitInfo $gitInfo ) { + $this->assertTrue( $gitInfo->cacheIsComplete() ); + $this->assertEquals( 'refs/heads/master', $gitInfo->getHead() ); $this->assertEquals( '0123456789abcdef0123456789abcdef01234567', - $fixture->getHeadSHA1() ); - $this->assertEquals( '1070884800', $fixture->getHeadCommitDate() ); - $this->assertEquals( 'master', $fixture->getCurrentBranch() ); + $gitInfo->getHeadSHA1() ); + $this->assertEquals( '1070884800', $gitInfo->getHeadCommitDate() ); + $this->assertEquals( 'master', $gitInfo->getCurrentBranch() ); $this->assertContains( '0123456789abcdef0123456789abcdef01234567', - $fixture->getHeadViewUrl() ); + $gitInfo->getHeadViewUrl() ); + + } + + public function testValidJsonData() { + global $IP; + + $this->assertValidGitInfo( new GitInfo( "$IP/testValidJsonData") ); + $this->assertValidGitInfo( new GitInfo( __DIR__ . "/../data/gitinfo/extension" ) ); } public function testMissingJsonData() { diff --git a/tests/phpunit/includes/ImportLinkCacheIntegrationTest.php b/tests/phpunit/includes/ImportLinkCacheIntegrationTest.php deleted file mode 100644 index 1433b898d0..0000000000 --- a/tests/phpunit/includes/ImportLinkCacheIntegrationTest.php +++ /dev/null @@ -1,112 +0,0 @@ -importStreamSource = ImportStreamSource::newFromFile( $file ); - - if ( !$this->importStreamSource->isGood() ) { - throw new Exception( "Import source for {$file} failed" ); - } - } - - public function testImportForImportSource() { - - $this->doImport( $this->importStreamSource ); - - // Imported title - $loremIpsum = Title::newFromText( 'Lorem ipsum' ); - - $this->assertSame( - $loremIpsum->getArticleID(), - $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) - ); - - $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' ); - - $this->assertSame( - $categoryLoremIpsum->getArticleID(), - $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) - ); - - $page = new WikiPage( $loremIpsum ); - $page->doDeleteArticle( 'import test: delete page' ); - - $page = new WikiPage( $categoryLoremIpsum ); - $page->doDeleteArticle( 'import test: delete page' ); - } - - /** - * @depends testImportForImportSource - */ - public function testReImportForImportSource() { - - $this->doImport( $this->importStreamSource ); - - // ReImported title - $loremIpsum = Title::newFromText( 'Lorem ipsum' ); - - $this->assertSame( - $loremIpsum->getArticleID(), - $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) - ); - - $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' ); - - $this->assertSame( - $categoryLoremIpsum->getArticleID(), - $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) - ); - } - - private function doImport( $importStreamSource ) { - - $importer = new WikiImporter( - $importStreamSource->value, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) - ); - $importer->setDebug( true ); - - $reporter = new ImportReporter( - $importer, - false, - '', - false - ); - - $reporter->setContext( new RequestContext() ); - $reporter->open(); - $exception = false; - - try { - $importer->doImport(); - } catch ( Exception $e ) { - $exception = $e; - } - - $result = $reporter->close(); - - $this->assertFalse( - $exception - ); - - $this->assertTrue( - $result->isGood() - ); - } - -} diff --git a/tests/phpunit/includes/ImportTest.php b/tests/phpunit/includes/ImportTest.php deleted file mode 100644 index 9c224309bb..0000000000 --- a/tests/phpunit/includes/ImportTest.php +++ /dev/null @@ -1,162 +0,0 @@ - - */ -class ImportTest extends MediaWikiLangTestCase { - - private function getDataSource( $xml ) { - return new ImportStringSource( $xml ); - } - - /** - * @covers WikiImporter::handlePage - * @dataProvider getRedirectXML - * @param string $xml - * @param string|null $redirectTitle - */ - public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { - $source = $this->getDataSource( $xml ); - - $redirect = null; - $callback = function ( Title $title, ForeignTitle $foreignTitle, $revCount, - $sRevCount, $pageInfo ) use ( &$redirect ) { - if ( array_key_exists( 'redirect', $pageInfo ) ) { - $redirect = $pageInfo['redirect']; - } - }; - - $importer = new WikiImporter( - $source, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) - ); - $importer->setPageOutCallback( $callback ); - $importer->doImport(); - - $this->assertEquals( $redirectTitle, $redirect ); - } - - public function getRedirectXML() { - // @codingStandardsIgnoreStart Generic.Files.LineLength - return array( - array( - <<< EOF - - - Test - 0 - 21 - - - 20 - 2014-05-27T10:00:00Z - - Admin - 10 - - Admin moved page [[Test]] to [[Test22]] - wikitext - text/x-wiki - #REDIRECT [[Test22]] - tq456o9x3abm7r9ozi6km8yrbbc56o6 - - - -EOF - , - 'Test22' - ), - array( - <<< EOF - - - Test - 0 - 42 - - 421 - 2014-05-27T11:00:00Z - - Admin - 10 - - Abcd - n7uomjq96szt60fy5w3x7ahf7q8m8rh - wikitext - text/x-wiki - - - -EOF - , - null - ), - ); - // @codingStandardsIgnoreEnd - } - - /** - * @covers WikiImporter::handleSiteInfo - * @dataProvider getSiteInfoXML - * @param string $xml - * @param array|null $namespaces - */ - public function testSiteInfoContainsNamespaces( $xml, $namespaces ) { - $source = $this->getDataSource( $xml ); - - $importNamespaces = null; - $callback = function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) { - $importNamespaces = $siteinfo['_namespaces']; - }; - - $importer = new WikiImporter( - $source, - ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) - ); - $importer->setSiteInfoCallback( $callback ); - $importer->doImport(); - - $this->assertEquals( $importNamespaces, $namespaces ); - } - - public function getSiteInfoXML() { - // @codingStandardsIgnoreStart Generic.Files.LineLength - return array( - array( - <<< EOF - - - - Media - Special - - Talk - User - User talk - Portal - Portal talk - - - -EOF - , - array( - '-2' => 'Media', - '-1' => 'Special', - '0' => '', - '1' => 'Talk', - '2' => 'User', - '3' => 'User talk', - '100' => 'Portal', - '101' => 'Portal talk', - ) - ), - ); - // @codingStandardsIgnoreEnd - } - -} diff --git a/tests/phpunit/includes/PagePropsTest.php b/tests/phpunit/includes/PagePropsTest.php new file mode 100644 index 0000000000..9a1f5973a3 --- /dev/null +++ b/tests/phpunit/includes/PagePropsTest.php @@ -0,0 +1,276 @@ +resetNamespaces(); # reset namespace cache + + if ( !$this->the_properties ) { + $this->the_properties = array( + "property1" => "value1", + "property2" => "value2", + "property3" => "value3", + "property4" => "value4" + ); + } + + if ( !$this->title1 ) { + $page = $this->createPage( + 'PagePropsTest_page_1', + "just a dummy page", + CONTENT_MODEL_WIKITEXT + ); + $this->title1 = $page->getTitle(); + $page1ID = $this->title1->getArticleID(); + $this->setProperties( $page1ID, $this->the_properties ); + } + + if ( !$this->title2 ) { + $page = $this->createPage( + 'PagePropsTest_page_2', + "just a dummy page", + CONTENT_MODEL_WIKITEXT + ); + $this->title2 = $page->getTitle(); + $page2ID = $this->title2->getArticleID(); + $this->setProperties( $page2ID, $this->the_properties ); + } + } + + protected function tearDown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::tearDown(); + + unset( $wgExtraNamespaces[12312] ); + unset( $wgExtraNamespaces[12313] ); + + unset( $wgNamespaceContentModels[12312] ); + unset( $wgContentHandlers['DUMMY'] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + /** + * Test getting a single property from a single page. The property was + * set in setUp(). + */ + public function testGetSingleProperty() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $result = $pageProps->getProperty( $this->title1, "property1" ); + $this->assertArrayHasKey( $page1ID, $result, "Found property" ); + $this->assertEquals( $result[$page1ID], "value1", "Get property" ); + } + + /** + * Test getting a single property from multiple pages. The property was + * set in setUp(). + */ + public function testGetSinglePropertyMultiplePages() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $page2ID = $this->title2->getArticleID(); + $titles = array( + $this->title1, + $this->title2 + ); + $result = $pageProps->getProperty( $titles, "property1" ); + $this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" ); + $this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" ); + $this->assertEquals( $result[$page1ID], "value1", "Get property page 1" ); + $this->assertEquals( $result[$page2ID], "value1", "Get property page 2" ); + } + + /** + * Test getting all properties from a single page. The properties were + * set in setUp(). The properties retrieved from the page may include + * additional properties not set in the test case that are added by + * other extensions. Therefore, rather than checking to see if the + * properties that were set in the test case exactly match the + * retrieved properties, we need to check to see if they are a + * subset of the retrieved properties. Since this version of PHPUnit + * does not yet include assertArraySubset(), we needed to code the + * equivalent functionality. + */ + public function testGetAllProperties() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $result = $pageProps->getProperties( $this->title1 ); + $this->assertArrayHasKey( $page1ID, $result, "Found properties" ); + $properties = $result[$page1ID]; + $patched = array_replace_recursive( $properties, $this->the_properties ); + $this->assertEquals( $patched, $properties, "Get all properties" ); + } + + /** + * Test getting all properties from multiple pages. The properties were + * set in setUp(). See getAllProperties() above for more information. + */ + public function testGetAllPropertiesMultiplePages() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $page2ID = $this->title2->getArticleID(); + $titles = array( + $this->title1, + $this->title2 + ); + $result = $pageProps->getProperties( $titles ); + $this->assertArrayHasKey( $page1ID, $result, "Found page 1 properties" ); + $this->assertArrayHasKey( $page2ID, $result, "Found page 2 properties" ); + $properties1 = $result[$page1ID]; + $patched = array_replace_recursive( $properties1, $this->the_properties ); + $this->assertEquals( $patched, $properties1, "Get all properties page 1" ); + $properties2 = $result[$page2ID]; + $patched = array_replace_recursive( $properties2, $this->the_properties ); + $this->assertEquals( $patched, $properties2, "Get all properties page 2" ); + } + + /** + * Test caching when retrieving single properties by getting a property, + * saving a new value for the property, then getting the property + * again. The cached value for the property rather than the new value + * of the property should be returned. + */ + public function testSingleCache() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $value1 = $pageProps->getProperty( $this->title1, "property1" ); + $this->setProperty( $page1ID, "property1", "another value" ); + $value2 = $pageProps->getProperty( $this->title1, "property1" ); + $this->assertEquals( $value1, $value2, "Single cache" ); + } + + /** + * Test caching when retrieving all properties by getting all + * properties, saving a new value for a property, then getting all + * properties again. The cached value for the properties rather than the + * new value of the properties should be returned. + */ + public function testMultiCache() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $properties1 = $pageProps->getProperties( $this->title1 ); + $this->setProperty( $page1ID, "property1", "another value" ); + $properties2 = $pageProps->getProperties( $this->title1 ); + $this->assertEquals( $properties1, $properties2, "Multi Cache" ); + } + + /** + * Test that getting all properties clears the single properties + * that have been cached by getting a property, saving a new value for + * the property, getting all properties (which clears the cached single + * properties), then getting the property again. The new value for the + * property rather than the cached value of the property should be + * returned. + */ + public function testClearCache() { + $pageProps = PageProps::getInstance(); + $page1ID = $this->title1->getArticleID(); + $pageProps->getProperty( $this->title1, "property1" ); + $new_value = "another value"; + $this->setProperty( $page1ID, "property1", $new_value ); + $pageProps->getProperties( $this->title1 ); + $result = $pageProps->getProperty( $this->title1, "property1" ); + $this->assertArrayHasKey( $page1ID, $result, "Found property" ); + $this->assertEquals( $result[$page1ID], "another value", "Clear cache" ); + } + + protected function createPage( $page, $text, $model = null ) { + if ( is_string( $page ) ) { + if ( !preg_match( '/:/', $page ) && + ( $model === null || $model === CONTENT_MODEL_WIKITEXT ) + ) { + $ns = $this->getDefaultWikitextNS(); + $page = MWNamespace::getCanonicalName( $ns ) . ':' . $page; + } + + $page = Title::newFromText( $page ); + } + + if ( $page instanceof Title ) { + $page = new WikiPage( $page ); + } + + if ( $page->exists() ) { + $page->doDeleteArticle( "done" ); + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); + + return $page; + } + + protected function setProperties( $pageID, $properties ) { + + $rows = array(); + + foreach ( $properties as $propertyName => $propertyValue ) { + + $row = array( + 'pp_page' => $pageID, + 'pp_propname' => $propertyName, + 'pp_value' => $propertyValue + ); + + $rows[] = $row; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->replace( + 'page_props', + array( + array( + 'pp_page', + 'pp_propname' + ) + ), + $rows, + __METHOD__ + ); + } + + protected function setProperty( $pageID, $propertyName, $propertyValue ) { + + $properties = array(); + $properties[$propertyName] = $propertyValue; + + $this->setProperties( $pageID, $properties ); + + } +} diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php new file mode 100644 index 0000000000..7099c3acea --- /dev/null +++ b/tests/phpunit/includes/TestLogger.php @@ -0,0 +1,105 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @author Brad Jorsch + */ + +use Psr\Log\LogLevel; + +/** + * A logger that may be configured to either buffer logs or to print them to + * the output where PHPUnit will complain about them. + * + * @since 1.27 + */ +class TestLogger extends \Psr\Log\AbstractLogger { + private $collect = false; + private $buffer = array(); + private $filter = null; + + /** + * @param bool $collect Whether to collect logs + * @param callable $filter Filter logs before collecting/printing. Signature is + * string|null function ( string $message, string $level ); + */ + public function __construct( $collect = false, $filter = null ) { + $this->collect = $collect; + $this->filter = $filter; + } + + /** + * Set the "collect" flag + * @param bool $collect + */ + public function setCollect( $collect ) { + $this->collect = $collect; + } + + /** + * Return the collected logs + * @return array Array of array( string $level, string $message ) + */ + public function getBuffer() { + return $this->buffer; + } + + /** + * Clear the collected log buffer + */ + public function clearBuffer() { + $this->buffer = array(); + } + + public function log( $level, $message, array $context = array() ) { + $message = trim( $message ); + + if ( $this->filter ) { + $message = call_user_func( $this->filter, $message, $level ); + if ( $message === null ) { + return; + } + } + + if ( $this->collect ) { + $this->buffer[] = array( $level, $message ); + } else { + switch ( $level ) { + case LogLevel::DEBUG: + case LogLevel::INFO: + case LogLevel::NOTICE: + trigger_error( "LOG[$level]: $message", E_USER_NOTICE ); + break; + + case LogLevel::WARNING: + trigger_error( "LOG[$level]: $message", E_USER_WARNING ); + break; + + case LogLevel::ERROR: + case LogLevel::CRITICAL: + case LogLevel::ALERT: + case LogLevel::EMERGENCY: + trigger_error( "LOG[$level]: $message", E_USER_ERROR ); + break; + } + } + } +} diff --git a/tests/phpunit/includes/api/ApiLoginTest.php b/tests/phpunit/includes/api/ApiLoginTest.php index 7dfd14f3d5..4085925dd0 100644 --- a/tests/phpunit/includes/api/ApiLoginTest.php +++ b/tests/phpunit/includes/api/ApiLoginTest.php @@ -13,9 +13,13 @@ class ApiLoginTest extends ApiTestCase { * Test result of attempted login with an empty username */ public function testApiLoginNoName() { + $session = array( + 'wsLoginToken' => 'foobar' + ); $data = $this->doApiRequest( array( 'action' => 'login', 'lgname' => '', 'lgpassword' => self::$users['sysop']->password, - ) ); + 'lgtoken' => 'foobar', + ), $session ); $this->assertEquals( 'NoName', $data[0]['login']['result'] ); } @@ -179,4 +183,94 @@ class ApiLoginTest extends ApiTestCase { $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] ); } + public function testBotPassword() { + global $wgServer, $wgSessionProviders; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $this->setMwGlobals( array( + 'wgSessionProviders' => array_merge( $wgSessionProviders, array( + array( + 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider', + 'args' => array( array( 'priority' => 40 ) ), + ) + ) ), + 'wgEnableBotPasswords' => true, + 'wgBotPasswordsDatabase' => false, + 'wgCentralIdLookupProvider' => 'local', + 'wgGrantPermissions' => array( + 'test' => array( 'read' => true ), + ), + ) ); + + // Make sure our session provider is present + $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() ); + if ( !isset( $manager->sessionProviders['MediaWiki\\Session\\BotPasswordSessionProvider'] ) ) { + $tmp = $manager->sessionProviders; + $manager->sessionProviders = null; + $manager->sessionProviders = $tmp + $manager->getProviders(); + } + $this->assertNotNull( + MediaWiki\Session\SessionManager::singleton()->getProvider( + 'MediaWiki\\Session\\BotPasswordSessionProvider' + ), + 'sanity check' + ); + + $user = self::$users['sysop']; + $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() ); + $this->assertNotEquals( 0, $centralId, 'sanity check' ); + + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( + 'bot_passwords', + array( + 'bp_user' => $centralId, + 'bp_app_id' => 'foo', + 'bp_password' => $pwhash->toString(), + 'bp_token' => '', + 'bp_restrictions' => MWRestrictions::newDefault()->toJson(), + 'bp_grants' => '["test"]', + ), + __METHOD__ + ); + + $lgName = $user->username . BotPassword::getSeparator() . 'foo'; + + $ret = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => $lgName, + 'lgpassword' => 'foobaz', + ) ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['login'] ); + + $a = $result['login']['result']; + $this->assertEquals( 'NeedToken', $a ); + $token = $result['login']['token']; + + $ret = $this->doApiRequest( array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => $lgName, + 'lgpassword' => 'foobaz', + ), $ret[2] ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $a = $result['login']['result']; + + $this->assertEquals( 'Success', $a ); + } + } diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index aef48158a0..f02f7dfdad 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -97,7 +97,8 @@ class ApiMainTest extends ApiTestCase { $request->setHeaders( $headers ); $request->response()->statusHeader( 200 ); // Why doesn't it default? - $api = new ApiMain( $request ); + $context = $this->apiContext->newTestContext( $request, null ); + $api = new ApiMain( $context ); $priv = TestingAccessWrapper::newFromObject( $api ); $priv->mInternalMode = false; diff --git a/tests/phpunit/includes/api/ApiResultTest.php b/tests/phpunit/includes/api/ApiResultTest.php index 9dbde3d93f..292d276b68 100644 --- a/tests/phpunit/includes/api/ApiResultTest.php +++ b/tests/phpunit/includes/api/ApiResultTest.php @@ -218,6 +218,17 @@ class ApiResultTest extends MediaWikiTestCase { 0 => "foo\xef\xbf\xbdbar", 1 => "\xc3\xa1", ), $arr ); + + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = array(); + ApiResult::setValue( $arr, 'foo', $obj ); + $this->assertSame( array( + 'foo' => array( + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ) + ), $arr ); } /** @@ -509,6 +520,19 @@ class ApiResultTest extends MediaWikiTestCase { 1 => "\xc3\xa1", ApiResult::META_TYPE => 'assoc', ), $result->getResultData() ); + + $result = new ApiResult( 8388608 ); + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = array(); + $result->addValue( $arr, 'foo', $obj ); + $this->assertSame( array( + 'foo' => array( + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ), + ApiResult::META_TYPE => 'assoc', + ), $result->getResultData() ); } /** diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php index 01113a6412..25ffcb7a92 100644 --- a/tests/phpunit/includes/api/ApiTestCase.php +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -47,11 +47,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { protected function tearDown() { // Avoid leaking session over tests - if ( session_id() != '' ) { - global $wgUser; - $wgUser->logout(); - session_destroy(); - } + MediaWiki\Session\SessionManager::getGlobalSession()->clear(); parent::tearDown(); } diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php index 87f794c1bf..b6ae641566 100644 --- a/tests/phpunit/includes/api/ApiTestCaseUpload.php +++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php @@ -15,8 +15,6 @@ abstract class ApiTestCaseUpload extends ApiTestCase { 'wgEnableAPI' => true, ) ); - wfSetupSession(); - $this->clearFakeUploads(); } diff --git a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php index 0b877275ee..552dacb7b9 100644 --- a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php +++ b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php @@ -212,7 +212,7 @@ class RCCacheEntryFactoryTest extends MediaWikiLangTestCase { ), 'child' => array( 'tag' => 'a', - 'content' => 'Talk', + 'content' => 'talk', ) ), $cacheEntry->usertalklink, diff --git a/tests/phpunit/includes/context/RequestContextTest.php b/tests/phpunit/includes/context/RequestContextTest.php index a9e5be2493..25969e6bb8 100644 --- a/tests/phpunit/includes/context/RequestContextTest.php +++ b/tests/phpunit/includes/context/RequestContextTest.php @@ -37,6 +37,14 @@ class RequestContextTest extends MediaWikiTestCase { * @covers RequestContext::importScopedSession */ public function testImportScopedSession() { + // Make sure session handling is started + if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) { + MediaWiki\Session\PHPSessionHandler::install( + MediaWiki\Session\SessionManager::singleton() + ); + } + $oldSessionId = session_id(); + $context = RequestContext::getMain(); $oInfo = $context->exportSession(); @@ -76,7 +84,16 @@ class RequestContextTest extends MediaWikiTestCase { $context->getRequest()->getAllHeaders(), "Correct context headers." ); - $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + $this->assertEquals( + $sinfo['sessionId'], + MediaWiki\Session\SessionManager::getGlobalSession()->getId(), + "Correct context session ID." + ); + if ( \MediaWiki\Session\PhpSessionHandler::isEnabled() ) { + $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + } else { + $this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." ); + } $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." ); $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." ); $this->assertEquals( diff --git a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php index 31e4f5b826..6403905e4e 100644 --- a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php +++ b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php @@ -256,4 +256,59 @@ class DatabaseMysqlBaseTest extends MediaWikiTestCase { $this->assertTrue( $pos2->hasReached( $pos1 ) ); $this->assertFalse( $pos1->hasReached( $pos2 ) ); } + + /** + * @dataProvider provideLagAmounts + */ + function testPtHeartbeat( $lag ) { + $db = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->setMethods( array( + 'getLagDetectionMethod', 'getHeartbeatData', 'getMasterServerInfo' ) ) + ->getMock(); + + $db->expects( $this->any() ) + ->method( 'getLagDetectionMethod' ) + ->will( $this->returnValue( 'pt-heartbeat' ) ); + + $db->expects( $this->any() ) + ->method( 'getMasterServerInfo' ) + ->will( $this->returnValue( array( 'serverId' => 172, 'asOf' => time() ) ) ); + + // Fake the current time. + list( $nowSecFrac, $nowSec ) = explode( ' ', microtime() ); + $now = (float)$nowSec + (float)$nowSecFrac; + // Fake the heartbeat time. + // Work arounds for weak DataTime microseconds support. + $ptTime = $now - $lag; + $ptSec = (int)$ptTime; + $ptSecFrac = ( $ptTime - $ptSec ); + $ptDateTime = new DateTime( "@$ptSec" ); + $ptTimeISO = $ptDateTime->format( 'Y-m-d\TH:i:s' ); + $ptTimeISO .= ltrim( number_format( $ptSecFrac, 6 ), '0' ); + + $db->expects( $this->any() ) + ->method( 'getHeartbeatData' ) + ->with( 172 ) + ->will( $this->returnValue( array( $ptTimeISO, $now ) ) ); + + $db->setLBInfo( 'clusterMasterHost', 'db1052' ); + $lagEst = $db->getLag(); + + $this->assertGreaterThan( $lag - .010, $lagEst, "Correct heatbeat lag" ); + $this->assertLessThan( $lag + .010, $lagEst, "Correct heatbeat lag" ); + } + + function provideLagAmounts() { + return array( + array( 0 ), + array( 0.3 ), + array( 6.5 ), + array( 10.1 ), + array( 200.2 ), + array( 400.7 ), + array( 600.22 ), + array( 1000.77 ), + ); + } } diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php index 519d3a0dfb..a64744555a 100644 --- a/tests/phpunit/includes/db/LBFactoryTest.php +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -103,9 +103,13 @@ class LBFactoryTest extends MediaWikiTestCase { $dbw = $lb->getConnection( DB_MASTER ); $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + $this->assertEquals( + $wgDBserver, $dbw->getLBInfo( 'clusterMasterHost' ), 'cluster master set' ); $dbr = $lb->getConnection( DB_SLAVE ); $this->assertTrue( $dbr->getLBInfo( 'slave' ), 'slave shows as slave' ); + $this->assertEquals( + $wgDBserver, $dbr->getLBInfo( 'clusterMasterHost' ), 'cluster master set' ); $factory->shutdown(); $lb->closeAll(); diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php index 9866ce140d..d2b267a060 100644 --- a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php +++ b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php @@ -167,7 +167,6 @@ class KafkaHandlerTest extends MediaWikiTestCase { } } - public function testBatchHandlesNullFormatterResult() { $produce = $this->getMockBuilder( 'Kafka\Produce' ) ->disableOriginalConstructor() diff --git a/tests/phpunit/includes/exception/HttpErrorTest.php b/tests/phpunit/includes/exception/HttpErrorTest.php index 66fe90c955..0aef146e2a 100644 --- a/tests/phpunit/includes/exception/HttpErrorTest.php +++ b/tests/phpunit/includes/exception/HttpErrorTest.php @@ -60,6 +60,4 @@ class HttpErrorTest extends MediaWikiTestCase { ) ); } - - } diff --git a/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php new file mode 100644 index 0000000000..5e3c6268aa --- /dev/null +++ b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php @@ -0,0 +1,112 @@ +importStreamSource = ImportStreamSource::newFromFile( $file ); + + if ( !$this->importStreamSource->isGood() ) { + throw new Exception( "Import source for {$file} failed" ); + } + } + + public function testImportForImportSource() { + + $this->doImport( $this->importStreamSource ); + + // Imported title + $loremIpsum = Title::newFromText( 'Lorem ipsum' ); + + $this->assertSame( + $loremIpsum->getArticleID(), + $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) + ); + + $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' ); + + $this->assertSame( + $categoryLoremIpsum->getArticleID(), + $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) + ); + + $page = new WikiPage( $loremIpsum ); + $page->doDeleteArticle( 'import test: delete page' ); + + $page = new WikiPage( $categoryLoremIpsum ); + $page->doDeleteArticle( 'import test: delete page' ); + } + + /** + * @depends testImportForImportSource + */ + public function testReImportForImportSource() { + + $this->doImport( $this->importStreamSource ); + + // ReImported title + $loremIpsum = Title::newFromText( 'Lorem ipsum' ); + + $this->assertSame( + $loremIpsum->getArticleID(), + $loremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) + ); + + $categoryLoremIpsum = Title::newFromText( 'Category:Lorem ipsum' ); + + $this->assertSame( + $categoryLoremIpsum->getArticleID(), + $categoryLoremIpsum->getArticleID( Title::GAID_FOR_UPDATE ) + ); + } + + private function doImport( $importStreamSource ) { + + $importer = new WikiImporter( + $importStreamSource->value, + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + $importer->setDebug( true ); + + $reporter = new ImportReporter( + $importer, + false, + '', + false + ); + + $reporter->setContext( new RequestContext() ); + $reporter->open(); + $exception = false; + + try { + $importer->doImport(); + } catch ( Exception $e ) { + $exception = $e; + } + + $result = $reporter->close(); + + $this->assertFalse( + $exception + ); + + $this->assertTrue( + $result->isGood() + ); + } + +} diff --git a/tests/phpunit/includes/import/ImportTest.php b/tests/phpunit/includes/import/ImportTest.php new file mode 100644 index 0000000000..f4aac235a2 --- /dev/null +++ b/tests/phpunit/includes/import/ImportTest.php @@ -0,0 +1,222 @@ + + */ +class ImportTest extends MediaWikiLangTestCase { + + private function getDataSource( $xml ) { + return new ImportStringSource( $xml ); + } + + /** + * @covers WikiImporter + * @dataProvider getUnknownTagsXML + * @param string $xml + * @param string $text + * @param string $title + */ + public function testUnknownXMLTags( $xml, $text, $title ) { + $source = $this->getDataSource( $xml ); + + $importer = new WikiImporter( + $source, + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + + $importer->doImport(); + $title = Title::newFromText( $title ); + $this->assertTrue( $title->exists() ); + + $this->assertEquals( WikiPage::factory( $title )->getContent()->getNativeData(), $text ); + } + + public function getUnknownTagsXML() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return array( + array( + <<< EOF + + + TestImportPage + Should be ignored + 0 + 14 + + 15 + Should be ignored + 2016-01-03T11:18:43Z + + Should be ignored + Admin + 1 + + wikitext + text/x-wiki + noitazinagro tseb eht si ikiWaideM + phoiac9h4m842xq45sp7s6u21eteeq1 + Should be ignored + + + Should be ignored + +EOF + , + 'noitazinagro tseb eht si ikiWaideM', + 'TestImportPage' + ) + ); + // @codingStandardsIgnoreEnd + } + + /** + * @covers WikiImporter::handlePage + * @dataProvider getRedirectXML + * @param string $xml + * @param string|null $redirectTitle + */ + public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { + $source = $this->getDataSource( $xml ); + + $redirect = null; + $callback = function ( Title $title, ForeignTitle $foreignTitle, $revCount, + $sRevCount, $pageInfo ) use ( &$redirect ) { + if ( array_key_exists( 'redirect', $pageInfo ) ) { + $redirect = $pageInfo['redirect']; + } + }; + + $importer = new WikiImporter( + $source, + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + $importer->setPageOutCallback( $callback ); + $importer->doImport(); + + $this->assertEquals( $redirectTitle, $redirect ); + } + + public function getRedirectXML() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return array( + array( + <<< EOF + + + Test + 0 + 21 + + + 20 + 2014-05-27T10:00:00Z + + Admin + 10 + + Admin moved page [[Test]] to [[Test22]] + wikitext + text/x-wiki + #REDIRECT [[Test22]] + tq456o9x3abm7r9ozi6km8yrbbc56o6 + + + +EOF + , + 'Test22' + ), + array( + <<< EOF + + + Test + 0 + 42 + + 421 + 2014-05-27T11:00:00Z + + Admin + 10 + + Abcd + n7uomjq96szt60fy5w3x7ahf7q8m8rh + wikitext + text/x-wiki + + + +EOF + , + null + ), + ); + // @codingStandardsIgnoreEnd + } + + /** + * @covers WikiImporter::handleSiteInfo + * @dataProvider getSiteInfoXML + * @param string $xml + * @param array|null $namespaces + */ + public function testSiteInfoContainsNamespaces( $xml, $namespaces ) { + $source = $this->getDataSource( $xml ); + + $importNamespaces = null; + $callback = function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) { + $importNamespaces = $siteinfo['_namespaces']; + }; + + $importer = new WikiImporter( + $source, + ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) + ); + $importer->setSiteInfoCallback( $callback ); + $importer->doImport(); + + $this->assertEquals( $importNamespaces, $namespaces ); + } + + public function getSiteInfoXML() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return array( + array( + <<< EOF + + + + Media + Special + + Talk + User + User talk + Portal + Portal talk + + + +EOF + , + array( + '-2' => 'Media', + '-1' => 'Special', + '0' => '', + '1' => 'Talk', + '2' => 'User', + '3' => 'User talk', + '100' => 'Portal', + '101' => 'Portal talk', + ) + ), + ); + // @codingStandardsIgnoreEnd + } + +} diff --git a/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php b/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php new file mode 100644 index 0000000000..178a6a603a --- /dev/null +++ b/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php @@ -0,0 +1,61 @@ + 'JobQueueMemory', + 'wiki' => wfWikiID(), + 'type' => 'null', + ) ); + } + + private function newJobSpecification() { + return new JobSpecification( + 'null', + array( 'customParameter' => null ), + array(), + Title::newFromText( 'Custom title' ) + ); + } + + public function testGetAllQueuedJobs() { + $queue = $this->newJobQueue(); + $this->assertCount( 0, $queue->getAllQueuedJobs() ); + + $queue->push( $this->newJobSpecification() ); + $this->assertCount( 1, $queue->getAllQueuedJobs() ); + } + + public function testGetAllAcquiredJobs() { + $queue = $this->newJobQueue(); + $this->assertCount( 0, $queue->getAllAcquiredJobs() ); + + $queue->push( $this->newJobSpecification() ); + $this->assertCount( 0, $queue->getAllAcquiredJobs() ); + + $queue->pop(); + $this->assertCount( 1, $queue->getAllAcquiredJobs() ); + } + + public function testJobFromSpecInternal() { + $queue = $this->newJobQueue(); + $job = $queue->jobFromSpecInternal( $this->newJobSpecification() ); + $this->assertInstanceOf( 'Job', $job ); + $this->assertSame( 'null', $job->getType() ); + $this->assertArrayHasKey( 'customParameter', $job->getParams() ); + $this->assertSame( 'Custom title', $job->getTitle()->getText() ); + } + +} diff --git a/tests/phpunit/includes/libs/MemoizedCallableTest.php b/tests/phpunit/includes/libs/MemoizedCallableTest.php index 921bba8466..6edb3d84d6 100644 --- a/tests/phpunit/includes/libs/MemoizedCallableTest.php +++ b/tests/phpunit/includes/libs/MemoizedCallableTest.php @@ -20,7 +20,6 @@ class ArrayBackedMemoizedCallable extends MemoizedCallable { } } - /** * PHP Unit tests for MemoizedCallable class. * @covers MemoizedCallable diff --git a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php index 94b74cb651..b9fd6ab81f 100644 --- a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php @@ -183,6 +183,18 @@ class BagOStuffTest extends MediaWikiTestCase { $this->assertEquals( $expectedValue, $actualValue, 'Value should be 1 after incrementing' ); } + /** + * @covers BagOStuff::incrWithInit + */ + public function testIncrWithInit() { + $key = wfMemcKey( 'test' ); + $val = $this->cache->incrWithInit( $key, 0, 1, 3 ); + $this->assertEquals( 3, $val, "Correct init value" ); + + $val = $this->cache->incrWithInit( $key, 0, 1, 3 ); + $this->assertEquals( 4, $val, "Correct init value" ); + } + /** * @covers BagOStuff::getMulti */ diff --git a/tests/phpunit/includes/logging/ProtectLogFormatterTest.php b/tests/phpunit/includes/logging/ProtectLogFormatterTest.php index 17decf36fa..8010b77383 100644 --- a/tests/phpunit/includes/logging/ProtectLogFormatterTest.php +++ b/tests/phpunit/includes/logging/ProtectLogFormatterTest.php @@ -160,7 +160,6 @@ class ProtectLogFormatterTest extends LogFormatterTestCase { ); } - /** * @dataProvider provideProtectLogDatabaseRows */ @@ -329,7 +328,6 @@ class ProtectLogFormatterTest extends LogFormatterTestCase { ); } - /** * @dataProvider provideModifyLogDatabaseRows */ @@ -362,7 +360,6 @@ class ProtectLogFormatterTest extends LogFormatterTestCase { ); } - /** * @dataProvider provideUnprotectLogDatabaseRows */ diff --git a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php index 8f28158dcd..cb10be3d27 100644 --- a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php +++ b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php @@ -11,7 +11,6 @@ abstract class MediaWikiMediaTestCase extends MediaWikiTestCase { /** @var string */ protected $filePath; - protected function setUp() { parent::setUp(); diff --git a/tests/phpunit/includes/media/XCFTest.php b/tests/phpunit/includes/media/XCFTest.php index 5b2de151f2..536827ae3a 100644 --- a/tests/phpunit/includes/media/XCFTest.php +++ b/tests/phpunit/includes/media/XCFTest.php @@ -13,7 +13,6 @@ class XCFHandlerTest extends MediaWikiMediaTestCase { $this->handler = new XCFHandler(); } - /** * @param string $filename * @param int $expectedWidth Width diff --git a/tests/phpunit/includes/page/WikiPageTest.php b/tests/phpunit/includes/page/WikiPageTest.php index 0a46f8a18c..7c0dd2ecf9 100644 --- a/tests/phpunit/includes/page/WikiPageTest.php +++ b/tests/phpunit/includes/page/WikiPageTest.php @@ -370,24 +370,6 @@ class WikiPageTest extends MediaWikiLangTestCase { $this->assertEquals( "some text", $text ); } - /** - * @covers WikiPage::getRawText - */ - public function testGetRawText() { - $this->hideDeprecated( "WikiPage::getRawText" ); - - $page = $this->newPage( "WikiPageTest_testGetRawText" ); - - $text = $page->getRawText(); - $this->assertFalse( $text ); - - # ----------------- - $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); - - $text = $page->getRawText(); - $this->assertEquals( "some text", $text ); - } - /** * @covers WikiPage::getContentModel */ @@ -1261,22 +1243,6 @@ more stuff ); } - /** - * @dataProvider providePreSaveTransform - * @covers WikiPage::preSaveTransform - */ - public function testPreSaveTransform( $text, $expected ) { - $this->hideDeprecated( 'WikiPage::preSaveTransform' ); - $user = new User(); - $user->setName( "127.0.0.1" ); - - // NOTE: assume Help namespace to contain wikitext - $page = $this->newPage( "Help:WikiPageTest_testPreloadTransform" ); - $text = $page->preSaveTransform( $text, $user ); - - $this->assertEquals( $expected, $text ); - } - /** * @covers WikiPage::factory */ diff --git a/tests/phpunit/includes/parser/NewParserTest.php b/tests/phpunit/includes/parser/NewParserTest.php index 5c6c17d7b9..256ad69183 100644 --- a/tests/phpunit/includes/parser/NewParserTest.php +++ b/tests/phpunit/includes/parser/NewParserTest.php @@ -304,6 +304,22 @@ class NewParserTest extends MediaWikiTestCase { ), $this->db->timestamp( '20010115123500' ), $user ); } + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Video.ogv' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( '', 'A pretty movie', 'Will it play', array( + 'size' => 12345, + 'width' => 240, + 'height' => 180, + 'bits' => 0, + 'media_type' => MEDIATYPE_VIDEO, + 'mime' => 'application/ogg', + 'metadata' => serialize( array() ), + 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20010115123500' ), $user ); + } + + # A DjVu file # A DjVu file $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) ); if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php index 1ebba1a5e2..b9402305bd 100644 --- a/tests/phpunit/includes/parser/PreprocessorTest.php +++ b/tests/phpunit/includes/parser/PreprocessorTest.php @@ -48,7 +48,7 @@ class PreprocessorTest extends MediaWikiTestCase { array( " Foo bar ", "<noinclude> Foo bar </noinclude>" ), array( "\n{{Foo}}\n", "<noinclude>\n\n</noinclude>" ), array( "\n{{Foo}}\n\n", "<noinclude>\n\n</noinclude>\n" ), - array( "foo bar", "galleryfoo bar" ), + array( "foo bar", "<gallery>foo bar" ), array( "<{{foo}}>", "<>" ), array( "<{{{foo}}}>", "<foo>" ), array( "", "gallery</gallery</gallery>" ), diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php index ddf552ef0d..590644fc06 100644 --- a/tests/phpunit/includes/registration/ExtensionProcessorTest.php +++ b/tests/phpunit/includes/registration/ExtensionProcessorTest.php @@ -118,7 +118,8 @@ class ExtensionProcessorTest extends MediaWikiTestCase { '_prefix' => 'eg', 'Bar' => 'somevalue' ), - ) + self::$default; + 'name' => 'FooBar2', + ); $processor->extractInfo( $this->dir, $info, 1 ); $processor->extractInfo( $this->dir, $info2, 1 ); $extracted = $processor->getExtractedInfo(); @@ -166,7 +167,6 @@ class ExtensionProcessorTest extends MediaWikiTestCase { } } - public static function provideExtractMessagesDirs() { $dir = __DIR__ . '/FooBar/'; return array( @@ -194,6 +194,16 @@ class ExtensionProcessorTest extends MediaWikiTestCase { } } + /** + * @covers ExtensionProcessor::extractCredits + */ + public function testExtractCredits() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default, 1 ); + $this->setExpectedException( 'Exception' ); + $processor->extractInfo( $this->dir, self::$default, 1 ); + } + /** * @covers ExtensionProcessor::extractResourceLoaderModules * @dataProvider provideExtractResourceLoaderModules @@ -400,7 +410,6 @@ class ExtensionProcessorTest extends MediaWikiTestCase { } } - /** * Allow overriding the default value of $this->globals * so we can test merging diff --git a/tests/phpunit/includes/registration/ExtensionRegistryTest.php b/tests/phpunit/includes/registration/ExtensionRegistryTest.php index 201cbfcdf1..543eb5c78d 100644 --- a/tests/phpunit/includes/registration/ExtensionRegistryTest.php +++ b/tests/phpunit/includes/registration/ExtensionRegistryTest.php @@ -25,6 +25,7 @@ class ExtensionRegistryTest extends MediaWikiTestCase { 'defines' => array(), 'credits' => array(), 'attributes' => array(), + 'autoloaderPaths' => array() ); $registry = new ExtensionRegistry(); $class = new ReflectionClass( 'ExtensionRegistry' ); diff --git a/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php new file mode 100644 index 0000000000..52872a4f14 --- /dev/null +++ b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php @@ -0,0 +1,288 @@ + 40, + 'sessionCookieName' => $name, + 'sessionCookieOptions' => array(), + ); + if ( $prefix !== null ) { + $params['sessionCookieOptions']['prefix'] = $prefix; + } + + if ( !$this->config ) { + $this->config = new \HashConfig( array( + 'CookiePrefix' => 'wgCookiePrefix', + 'EnableBotPasswords' => true, + 'BotPasswordsDatabase' => false, + 'SessionProviders' => $wgSessionProviders + array( + 'MediaWiki\\Session\\BotPasswordSessionProvider' => array( + 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider', + 'args' => array( $params ), + ) + ), + ) ); + } + $manager = new SessionManager( array( + 'config' => new \MultiConfig( array( $this->config, \RequestContext::getMain()->getConfig() ) ), + 'logger' => new \Psr\Log\NullLogger, + 'store' => new TestBagOStuff, + ) ); + + return $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' ); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableBotPasswords' => true, + 'wgBotPasswordsDatabase' => false, + 'wgCentralIdLookupProvider' => 'local', + 'wgGrantPermissions' => array( + 'test' => array( 'read' => true ), + ), + ) ); + } + + public function addDBData() { + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + + $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( 'UTSysop' ); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( + 'bot_passwords', + array( 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ), + __METHOD__ + ); + $dbw->insert( + 'bot_passwords', + array( + 'bp_user' => $userId, + 'bp_app_id' => 'BotPasswordSessionProvider', + 'bp_password' => $pwhash->toString(), + 'bp_token' => 'token!', + 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', + 'bp_grants' => '["test"]', + ), + __METHOD__ + ); + } + + public function testConstructor() { + try { + $provider = new BotPasswordSessionProvider(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified', + $ex->getMessage() + ); + } + + try { + $provider = new BotPasswordSessionProvider( array( + 'priority' => SessionInfo::MIN_PRIORITY - 1 + ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + + try { + $provider = new BotPasswordSessionProvider( array( + 'priority' => SessionInfo::MAX_PRIORITY + 1 + ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + + $provider = new BotPasswordSessionProvider( array( + 'priority' => 40 + ) ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertSame( 40, $priv->priority ); + $this->assertSame( '_BPsession', $priv->sessionCookieName ); + $this->assertSame( array(), $priv->sessionCookieOptions ); + + $provider = new BotPasswordSessionProvider( array( + 'priority' => 40, + 'sessionCookieName' => null, + ) ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertSame( '_BPsession', $priv->sessionCookieName ); + + $provider = new BotPasswordSessionProvider( array( + 'priority' => 40, + 'sessionCookieName' => 'Foo', + 'sessionCookieOptions' => array( 'Bar' ), + ) ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertSame( 'Foo', $priv->sessionCookieName ); + $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions ); + } + + public function testBasics() { + $provider = $this->getProvider(); + + $this->assertTrue( $provider->persistsSessionID() ); + $this->assertFalse( $provider->canChangeUser() ); + + $this->assertNull( $provider->newSessionInfo() ); + $this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) ); + } + + public function testProvideSessionInfo() { + $provider = $this->getProvider(); + $request = new \FauxRequest; + $request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' ); + + if ( !defined( 'MW_API' ) ) { + $this->assertNull( $provider->provideSessionInfo( $request ) ); + define( 'MW_API', 1 ); + } + + $info = $provider->provideSessionInfo( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\SessionInfo', $info ); + $this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() ); + + $this->config->set( 'EnableBotPasswords', false ); + $this->assertNull( $provider->provideSessionInfo( $request ) ); + $this->config->set( 'EnableBotPasswords', true ); + + $this->assertNull( $provider->provideSessionInfo( new \FauxRequest ) ); + } + + public function testNewSessionInfoForRequest() { + $provider = $this->getProvider(); + $user = \User::newFromName( 'UTSysop' ); + $request = $this->getMock( 'FauxRequest', array( 'getIP' ) ); + $request->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( '127.0.0.1' ) ); + $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' ); + + $session = $provider->newSessionForRequest( $user, $bp, $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + + $this->assertEquals( $session->getId(), $request->getSession()->getId() ); + $this->assertEquals( $user->getName(), $session->getUser()->getName() ); + + $this->assertEquals( array( + 'centralId' => $bp->getUserCentralId(), + 'appId' => $bp->getAppId(), + 'token' => $bp->getToken(), + 'rights' => array( 'read' ), + ), $session->getProviderMetadata() ); + + $this->assertEquals( array( 'read' ), $session->getAllowedUserRights() ); + } + + public function testCheckSessionInfo() { + $logger = new \TestLogger( true, function ( $m ) { + return preg_replace( + '/^Session \[\d+\][a-zA-Z0-9_\\\\]+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m + ); + } ); + $provider = $this->getProvider(); + $provider->setLogger( $logger ); + + $user = \User::newFromName( 'UTSysop' ); + $request = $this->getMock( 'FauxRequest', array( 'getIP' ) ); + $request->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( '127.0.0.1' ) ); + $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' ); + + $data = array( + 'provider' => $provider, + 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'userInfo' => UserInfo::newFromUser( $user, true ), + 'persisted' => false, + 'metadata' => array( + 'centralId' => $bp->getUserCentralId(), + 'appId' => $bp->getAppId(), + 'token' => $bp->getToken(), + ), + ); + $dataMD = $data['metadata']; + + foreach ( array_keys( $data['metadata'] ) as $key ) { + $data['metadata'] = $dataMD; + unset( $data['metadata'][$key] ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data ); + $metadata = $info->getProviderMetadata(); + + $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) ); + $this->assertSame( array( + array( LogLevel::INFO, "Session X: Missing metadata: $key" ) + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + + $data['metadata'] = $dataMD; + $data['metadata']['appId'] = 'Foobar'; + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data ); + $metadata = $info->getProviderMetadata(); + $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) ); + $this->assertSame( array( + array( LogLevel::INFO, "Session X: No BotPassword for {$bp->getUserCentralId()} Foobar" ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $data['metadata'] = $dataMD; + $data['metadata']['token'] = 'Foobar'; + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data ); + $metadata = $info->getProviderMetadata(); + $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'Session X: BotPassword token check failed' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $request2 = $this->getMock( 'FauxRequest', array( 'getIP' ) ); + $request2->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( '10.0.0.1' ) ); + $data['metadata'] = $dataMD; + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data ); + $metadata = $info->getProviderMetadata(); + $this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'Session X: Restrictions check failed' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data ); + $metadata = $info->getProviderMetadata(); + $this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) ); + $this->assertSame( array(), $logger->getBuffer() ); + $this->assertEquals( $dataMD + array( 'rights' => array( 'read' ) ), $metadata ); + } +} diff --git a/tests/phpunit/includes/session/CookieSessionProviderTest.php b/tests/phpunit/includes/session/CookieSessionProviderTest.php new file mode 100644 index 0000000000..a73bf7c098 --- /dev/null +++ b/tests/phpunit/includes/session/CookieSessionProviderTest.php @@ -0,0 +1,726 @@ + 'CookiePrefix', + 'CookiePath' => 'CookiePath', + 'CookieDomain' => 'CookieDomain', + 'CookieSecure' => true, + 'CookieHttpOnly' => true, + 'SessionName' => false, + 'ExtendedLoginCookies' => array( 'UserID', 'Token' ), + 'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2, + ) ); + } + + public function testConstructor() { + try { + new CookieSessionProvider(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified', + $ex->getMessage() + ); + } + + try { + new CookieSessionProvider( array( 'priority' => 'foo' ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + try { + new CookieSessionProvider( array( 'priority' => SessionInfo::MIN_PRIORITY - 1 ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + try { + new CookieSessionProvider( array( 'priority' => SessionInfo::MAX_PRIORITY + 1 ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + + try { + new CookieSessionProvider( array( 'priority' => 1, 'cookieOptions' => null ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array', + $ex->getMessage() + ); + } + + $config = $this->getConfig(); + $p = \TestingAccessWrapper::newFromObject( + new CookieSessionProvider( array( 'priority' => 1 ) ) + ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 1, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => false, + 'sessionName' => 'CookiePrefix_session', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'CookiePrefix', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => true, + 'httpOnly' => true, + ), $p->cookieOptions ); + + $config->set( 'SessionName', 'SessionName' ); + $p = \TestingAccessWrapper::newFromObject( + new CookieSessionProvider( array( 'priority' => 3 ) ) + ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 3, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => false, + 'sessionName' => 'SessionName', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'CookiePrefix', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => true, + 'httpOnly' => true, + ), $p->cookieOptions ); + + $p = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array( + 'priority' => 10, + 'callUserSetCookiesHook' => true, + 'cookieOptions' => array( + 'prefix' => 'XPrefix', + 'path' => 'XPath', + 'domain' => 'XDomain', + 'secure' => 'XSecure', + 'httpOnly' => 'XHttpOnly', + ), + 'sessionName' => 'XSession', + ) ) ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 10, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => true, + 'sessionName' => 'XSession', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'XPrefix', + 'path' => 'XPath', + 'domain' => 'XDomain', + 'secure' => 'XSecure', + 'httpOnly' => 'XHttpOnly', + ), $p->cookieOptions ); + } + + public function testBasics() { + $provider = new CookieSessionProvider( array( 'priority' => 10 ) ); + + $this->assertTrue( $provider->persistsSessionID() ); + $this->assertTrue( $provider->canChangeUser() ); + + $msg = $provider->whyNoSession(); + $this->assertInstanceOf( 'Message', $msg ); + $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() ); + } + + public function testProvideSessionInfo() { + $params = array( + 'priority' => 20, + 'sessionName' => 'session', + 'cookieOptions' => array( 'prefix' => 'x' ), + ); + $provider = new CookieSessionProvider( $params ); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( new SessionManager() ); + + $user = User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + $token = $user->getToken( true ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + // No data + $request = new \FauxRequest(); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // Session key only + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNull( $info->getUserInfo() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User, no session key + $request = new \FauxRequest(); + $request->setCookies( array( + 'xUserID' => $id, + 'xToken' => $token, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertNotSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User and session key + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => $token, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User with bad token + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => 'BADTOKEN', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User id with no token + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertFalse( $info->getUserInfo()->isVerified() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + $request = new \FauxRequest(); + $request->setCookies( array( + 'xUserID' => $id, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User and session key, with forceHTTPS flag + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => $token, + 'forceHTTPS' => true, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertTrue( $info->forceHTTPS() ); + + // Invalid user id + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => '-1', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User id with matching name + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xUserName' => $name, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertFalse( $info->getUserInfo()->isVerified() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User id with wrong name + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xUserName' => 'Wrong', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + } + + public function testGetVaryCookies() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'MyCookiePrefix' ), + ) ); + $this->assertArrayEquals( array( + 'MyCookiePrefixToken', + 'MyCookiePrefixLoggedOut', + 'MySessionName', + 'forceHTTPS', + ), $provider->getVaryCookies() ); + } + + public function testSuggestLoginUsername() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + + $request = new \FauxRequest(); + $this->assertEquals( null, $provider->suggestLoginUsername( $request ) ); + + $request->setCookies( array( + 'xUserName' => 'Example', + ), '' ); + $this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) ); + } + + public function testPersistSession() { + $this->setMwGlobals( array( 'wgCookieExpiration' => 100 ) ); + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => false, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $config = $this->getConfig(); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $store = new \HashBagOStuff(); + $user = User::newFromName( 'UTSysop' ); + $anon = new User; + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + $store, + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + + $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) ); + $mock->expects( $this->never() )->method( 'onUserSetCookies' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + + // Anonymous user + $backend->setUser( $anon ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + // Logged-in user, no remember + $backend->setUser( $user ); + $backend->setRememberUser( false ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + // Logged-in user, remember + $backend->setUser( $user ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( true ); + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + } + + /** + * @dataProvider provideCookieData + * @param bool $secure + * @param bool $remember + */ + public function testCookieData( $secure, $remember ) { + $this->setMwGlobals( array( + 'wgCookieExpiration' => 100, + 'wgSecureLogin' => false, + ) ); + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => false, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $config = $this->getConfig(); + $config->set( 'CookieSecure', false ); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $user = User::newFromName( 'UTSysop' ); + $this->assertFalse( $user->requiresHTTPS(), 'sanity check' ); + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + new \EmptyBagOStuff(), + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + $backend->setUser( $user ); + $backend->setRememberUser( $remember ); + $backend->setForceHTTPS( $secure ); + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + + $defaults = array( + 'expire' => (int)100, + 'path' => $config->get( 'CookiePath' ), + 'domain' => $config->get( 'CookieDomain' ), + 'secure' => $secure, + 'httpOnly' => $config->get( 'CookieHttpOnly' ), + 'raw' => false, + ); + $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' ); + $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry ); + $this->assertEquals( array( 'UserID', 'Token' ), $config->get( 'ExtendedLoginCookies' ), + 'sanity check' ); + $expect = array( + 'MySessionName' => array( + 'value' => (string)$sessionId, + 'expire' => 0, + ) + $defaults, + 'xUserID' => array( + 'value' => (string)$user->getId(), + 'expire' => $extendedExpiry, + ) + $defaults, + 'xUserName' => array( + 'value' => $user->getName(), + ) + $defaults, + 'xToken' => array( + 'value' => $remember ? $user->getToken() : '', + 'expire' => $remember ? $extendedExpiry : -31536000, + ) + $defaults, + 'forceHTTPS' => !$secure ? null : array( + 'value' => 'true', + 'secure' => false, + 'expire' => $remember ? $defaults['expire'] : null, + ) + $defaults, + ); + foreach ( $expect as $key => $value ) { + $actual = $request->response()->getCookieData( $key ); + if ( $actual && $actual['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $actual['expire'] = round( $actual['expire'] - $time, -2 ); + } + $this->assertEquals( $value, $actual, "Cookie $key" ); + } + } + + public static function provideCookieData() { + return array( + array( false, false ), + array( false, true ), + array( true, false ), + array( true, true ), + ); + } + + protected function getSentRequest() { + $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) ); + $sentResponse->expects( $this->any() )->method( 'headersSent' ) + ->will( $this->returnValue( true ) ); + $sentResponse->expects( $this->never() )->method( 'setCookie' ); + $sentResponse->expects( $this->never() )->method( 'header' ); + + $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) ); + $sentRequest->expects( $this->any() )->method( 'response' ) + ->will( $this->returnValue( $sentResponse ) ); + return $sentRequest; + } + + public function testPersistSessionWithHook() { + $that = $this; + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => true, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $store = new \HashBagOStuff(); + $user = User::newFromName( 'UTSysop' ); + $anon = new User; + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + $store, + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + + // Anonymous user + $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) ); + $mock->expects( $this->never() )->method( 'onUserSetCookies' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $anon ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + + // Logged-in user, no remember + $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) ); + $mock->expects( $this->once() )->method( 'onUserSetCookies' ) + ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) { + $that->assertSame( $user, $u ); + $that->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + ), $sessionData ); + $that->assertEquals( array( + 'UserID' => $user->getId(), + 'UserName' => $user->getName(), + 'Token' => false, + ), $cookies ); + + $sessionData['foo'] = 'foo!'; + $cookies['bar'] = 'bar!'; + return true; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $user ); + $backend->setRememberUser( false ); + $backend->setForceHTTPS( false ); + $backend->setLoggedOutTimestamp( $loggedOut = time() ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( 'bar!', $request->response()->getCookie( 'xbar' ) ); + $this->assertSame( (string)$loggedOut, $request->response()->getCookie( 'xLoggedOut' ) ); + $this->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + 'foo' => 'foo!', + ), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + + // Logged-in user, remember + $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) ); + $mock->expects( $this->once() )->method( 'onUserSetCookies' ) + ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) { + $that->assertSame( $user, $u ); + $that->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + ), $sessionData ); + $that->assertEquals( array( + 'UserID' => $user->getId(), + 'UserName' => $user->getName(), + 'Token' => $user->getToken(), + ), $cookies ); + + $sessionData['foo'] = 'foo 2!'; + $cookies['bar'] = 'bar 2!'; + return true; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $user ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( true ); + $backend->setLoggedOutTimestamp( 0 ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( 'bar 2!', $request->response()->getCookie( 'xbar' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + $this->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + 'foo' => 'foo 2!', + ), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + } + + public function testUnpersistSession() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) ); + + $provider->unpersistSession( $this->getSentRequest() ); + } + + public function testSetLoggedOutCookie() { + $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $t1 = time(); + $t2 = time() - 86400 * 2; + + // Set it + $request = new \FauxRequest(); + $provider->setLoggedOutCookie( $t1, $request ); + $this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) ); + + // Too old + $request = new \FauxRequest(); + $provider->setLoggedOutCookie( $t2, $request ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + + // Don't reset if it's already set + $request = new \FauxRequest(); + $request->setCookies( array( + 'xLoggedOut' => $t1, + ), '' ); + $provider->setLoggedOutCookie( $t1, $request ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + } + + /** + * To be mocked for hooks, since PHPUnit can't otherwise mock methods that + * take references. + */ + public function onUserSetCookies( $user, &$sessionData, &$cookies ) { + } + +} diff --git a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php new file mode 100644 index 0000000000..e06dfd5555 --- /dev/null +++ b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php @@ -0,0 +1,301 @@ +set( 'CookiePrefix', 'wgCookiePrefix' ); + + $params = array( + 'sessionCookieName' => $name, + 'sessionCookieOptions' => array(), + ); + if ( $prefix !== null ) { + $params['sessionCookieOptions']['prefix'] = $prefix; + } + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( $params ) ) + ->getMockForAbstractClass(); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( new SessionManager() ); + + return $provider; + } + + public function testConstructor() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->getMockForAbstractClass(); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertNull( $priv->sessionCookieName ); + $this->assertSame( array(), $priv->sessionCookieOptions ); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieName' => 'Foo', + 'sessionCookieOptions' => array( 'Bar' ), + ) ) ) + ->getMockForAbstractClass(); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertSame( 'Foo', $priv->sessionCookieName ); + $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions ); + + try { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieName' => false, + ) ) ) + ->getMockForAbstractClass(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'sessionCookieName must be a string', + $ex->getMessage() + ); + } + + try { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieOptions' => 'x', + ) ) ) + ->getMockForAbstractClass(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'sessionCookieOptions must be an array', + $ex->getMessage() + ); + } + } + + public function testBasics() { + $provider = $this->getProvider( null ); + $this->assertFalse( $provider->persistsSessionID() ); + $this->assertFalse( $provider->canChangeUser() ); + + $provider = $this->getProvider( 'Foo' ); + $this->assertTrue( $provider->persistsSessionID() ); + $this->assertFalse( $provider->canChangeUser() ); + + $msg = $provider->whyNoSession(); + $this->assertInstanceOf( 'Message', $msg ); + $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() ); + } + + public function testGetVaryCookies() { + $provider = $this->getProvider( null ); + $this->assertSame( array(), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo' ); + $this->assertSame( array( 'wgCookiePrefixFoo' ), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo', 'Bar' ); + $this->assertSame( array( 'BarFoo' ), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo', '' ); + $this->assertSame( array( 'Foo' ), $provider->getVaryCookies() ); + } + + public function testGetSessionIdFromCookie() { + $this->setMwGlobals( 'wgCookiePrefix', 'wgCookiePrefix' ); + $request = new \FauxRequest(); + $request->setCookies( array( + '' => 'empty---------------------------', + 'Foo' => 'foo-----------------------------', + 'wgCookiePrefixFoo' => 'wgfoo---------------------------', + 'BarFoo' => 'foobar--------------------------', + 'bad' => 'bad', + ), '' ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( null ) ); + try { + $provider->getSessionIdFromCookie( $request ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' . + 'may not be called when $this->sessionCookieName === null', + $ex->getMessage() + ); + } + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) ); + $this->assertSame( + 'wgfoo---------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) ); + $this->assertSame( + 'foobar--------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) ); + $this->assertSame( + 'foo-----------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) ); + $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) ); + $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) ); + } + + protected function getSentRequest() { + $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) ); + $sentResponse->expects( $this->any() )->method( 'headersSent' ) + ->will( $this->returnValue( true ) ); + $sentResponse->expects( $this->never() )->method( 'setCookie' ); + $sentResponse->expects( $this->never() )->method( 'header' ); + + $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) ); + $sentRequest->expects( $this->any() )->method( 'response' ) + ->will( $this->returnValue( $sentResponse ) ); + return $sentRequest; + } + + /** + * @dataProvider providePersistSession + * @param bool $secure + * @param bool $remember + */ + public function testPersistSession( $secure, $remember ) { + $this->setMwGlobals( array( + 'wgCookieExpiration' => 100, + 'wgSecureLogin' => false, + ) ); + + $provider = $this->getProvider( 'session' ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $priv->sessionCookieOptions = array( + 'prefix' => 'x', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => false, + 'httpOnly' => true, + ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $user = User::newFromName( 'UTSysop' ); + $this->assertFalse( $user->requiresHTTPS(), 'sanity check' ); + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $user, true ), + 'idIsSafe' => true, + ) ), + new \EmptyBagOStuff(), + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + $backend->setRememberUser( $remember ); + $backend->setForceHTTPS( $secure ); + + // No cookie + $priv->sessionCookieName = null; + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( array(), $request->response()->getCookies() ); + + // Cookie + $priv->sessionCookieName = 'session'; + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + + $cookie = $request->response()->getCookieData( 'xsession' ); + $this->assertInternalType( 'array', $cookie ); + if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $cookie['expire'] = round( $cookie['expire'] - $time, -2 ); + } + $this->assertEquals( array( + 'value' => $sessionId, + 'expire' => null, + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => $secure, + 'httpOnly' => true, + 'raw' => false, + ), $cookie ); + + $cookie = $request->response()->getCookieData( 'forceHTTPS' ); + if ( $secure ) { + $this->assertInternalType( 'array', $cookie ); + if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $cookie['expire'] = round( $cookie['expire'] - $time, -2 ); + } + $this->assertEquals( array( + 'value' => 'true', + 'expire' => $remember ? 100 : null, + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => false, + 'httpOnly' => true, + 'raw' => false, + ), $cookie ); + } else { + $this->assertNull( $cookie ); + } + + // Headers sent + $request = $this->getSentRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( array(), $request->response()->getCookies() ); + } + + public static function providePersistSession() { + return array( + array( false, false ), + array( false, true ), + array( true, false ), + array( true, true ), + ); + } + + public function testUnpersistSession() { + $provider = $this->getProvider( 'session', '' ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + // No cookie + $priv->sessionCookieName = null; + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( null, $request->response()->getCookie( 'session', '' ) ); + + // Cookie + $priv->sessionCookieName = 'session'; + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( '', $request->response()->getCookie( 'session', '' ) ); + + // Headers sent + $request = $this->getSentRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( null, $request->response()->getCookie( 'session', '' ) ); + } + +} diff --git a/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/tests/phpunit/includes/session/PHPSessionHandlerTest.php new file mode 100644 index 0000000000..5a5df6f2f3 --- /dev/null +++ b/tests/phpunit/includes/session/PHPSessionHandlerTest.php @@ -0,0 +1,371 @@ +setAccessible( true ); + if ( $rProp->getValue() ) { + $old = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $oldManager = $old->manager; + $oldStore = $old->store; + $oldLogger = $old->logger; + $reset[] = new \ScopedCallback( + array( 'MediaWiki\\Session\\PHPSessionHandler', 'install' ), + array( $oldManager, $oldStore, $oldLogger ) + ); + } + + return $reset; + } + + public function testEnableFlags() { + $handler = \TestingAccessWrapper::newFromObject( + $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock() + ); + + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $rProp->getValue() ) ); + $rProp->setValue( $handler ); + + $handler->setEnableFlags( 'enable' ); + $this->assertTrue( $handler->enable ); + $this->assertFalse( $handler->warn ); + $this->assertTrue( PHPSessionHandler::isEnabled() ); + + $handler->setEnableFlags( 'warn' ); + $this->assertTrue( $handler->enable ); + $this->assertTrue( $handler->warn ); + $this->assertTrue( PHPSessionHandler::isEnabled() ); + + $handler->setEnableFlags( 'disable' ); + $this->assertFalse( $handler->enable ); + $this->assertFalse( PHPSessionHandler::isEnabled() ); + + $rProp->setValue( null ); + $this->assertFalse( PHPSessionHandler::isEnabled() ); + } + + public function testInstall() { + $reset = $this->getResetter( $rProp ); + $rProp->setValue( null ); + + session_write_close(); + ini_set( 'session.use_cookies', 1 ); + ini_set( 'session.use_trans_sid', 1 ); + + $store = new \HashBagOStuff(); + $logger = new \TestLogger(); + $manager = new SessionManager( array( + 'store' => $store, + 'logger' => $logger, + ) ); + + $this->assertFalse( PHPSessionHandler::isInstalled() ); + PHPSessionHandler::install( $manager ); + $this->assertTrue( PHPSessionHandler::isInstalled() ); + + $this->assertFalse( wfIniGetBool( 'session.use_cookies' ) ); + $this->assertFalse( wfIniGetBool( 'session.use_trans_sid' ) ); + + $this->assertNotNull( $rProp->getValue() ); + $priv = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $this->assertSame( $manager, $priv->manager ); + $this->assertSame( $store, $priv->store ); + $this->assertSame( $logger, $priv->logger ); + } + + /** + * @dataProvider provideHandlers + * @param string $handler php serialize_handler to use + */ + public function testSessionHandling( $handler ) { + $this->hideDeprecated( '$_SESSION' ); + $reset[] = $this->getResetter( $rProp ); + + $this->setMwGlobals( array( + 'wgSessionProviders' => array( array( 'class' => 'DummySessionProvider' ) ), + 'wgObjectCacheSessionExpiry' => 2, + ) ); + + $store = new \HashBagOStuff(); + $logger = new \TestLogger( true, function ( $m ) { + return preg_match( '/^SessionBackend a{32} /', $m ) ? null : $m; + } ); + $manager = new SessionManager( array( + 'store' => $store, + 'logger' => $logger, + ) ); + PHPSessionHandler::install( $manager ); + $wrap = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $reset[] = new \ScopedCallback( + array( $wrap, 'setEnableFlags' ), + array( $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' ) + ); + $wrap->setEnableFlags( 'warn' ); + + \MediaWiki\suppressWarnings(); + ini_set( 'session.serialize_handler', $handler ); + \MediaWiki\restoreWarnings(); + if ( ini_get( 'session.serialize_handler' ) !== $handler ) { + $this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" ); + } + + // Session IDs for testing + $sessionA = str_repeat( 'a', 32 ); + $sessionB = str_repeat( 'b', 32 ); + $sessionC = str_repeat( 'c', 32 ); + + // Set up garbage data in the session + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + + session_id( $sessionA ); + session_start(); + $this->assertSame( array(), $_SESSION ); + $this->assertSame( $sessionA, session_id() ); + + // Set some data in the session so we can see if it works. + $rand = mt_rand(); + $_SESSION['AuthenticationSessionTest'] = $rand; + $expect = array( 'AuthenticationSessionTest' => $rand ); + session_write_close(); + $this->assertSame( array( + array( LogLevel::WARNING, 'Something wrote to $_SESSION!' ), + ), $logger->getBuffer() ); + + // Screw up $_SESSION so we can tell the difference between "this + // worked" and "this did nothing" + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + + // Re-open the session and see that data was actually reloaded + session_start(); + $this->assertSame( $expect, $_SESSION ); + + // Make sure session_reset() works too. + if ( function_exists( 'session_reset' ) ) { + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + session_reset(); + $this->assertSame( $expect, $_SESSION ); + } + + // Test expiry + session_write_close(); + ini_set( 'session.gc_divisor', 1 ); + ini_set( 'session.gc_probability', 1 ); + sleep( 3 ); + session_start(); + $this->assertSame( array(), $_SESSION ); + + // Re-fill the session, then test that session_destroy() works. + $_SESSION['AuthenticationSessionTest'] = $rand; + session_write_close(); + session_start(); + $this->assertSame( $expect, $_SESSION ); + session_destroy(); + session_id( $sessionA ); + session_start(); + $this->assertSame( array(), $_SESSION ); + session_write_close(); + + // Test that our session handler won't clone someone else's session + session_id( $sessionB ); + session_start(); + $this->assertSame( $sessionB, session_id() ); + $_SESSION['id'] = 'B'; + session_write_close(); + + session_id( $sessionC ); + session_start(); + $this->assertSame( array(), $_SESSION ); + $_SESSION['id'] = 'C'; + session_write_close(); + + session_id( $sessionB ); + session_start(); + $this->assertSame( array( 'id' => 'B' ), $_SESSION ); + session_write_close(); + + session_id( $sessionC ); + session_start(); + $this->assertSame( array( 'id' => 'C' ), $_SESSION ); + session_destroy(); + + session_id( $sessionB ); + session_start(); + $this->assertSame( array( 'id' => 'B' ), $_SESSION ); + + // Test merging between Session and $_SESSION + session_write_close(); + + $session = $manager->getEmptySession(); + $session->set( 'Unchanged', 'setup' ); + $session->set( 'Changed in $_SESSION', 'setup' ); + $session->set( 'Changed in Session', 'setup' ); + $session->set( 'Changed in both', 'setup' ); + $session->set( 'Deleted in Session', 'setup' ); + $session->set( 'Deleted in $_SESSION', 'setup' ); + $session->set( 'Deleted in both', 'setup' ); + $session->set( 'Deleted in Session, changed in $_SESSION', 'setup' ); + $session->set( 'Deleted in $_SESSION, changed in Session', 'setup' ); + $session->persist(); + $session->save(); + + session_id( $session->getId() ); + session_start(); + $session->set( 'Added in Session', 'Session' ); + $session->set( 'Added in both', 'Session' ); + $session->set( 'Changed in Session', 'Session' ); + $session->set( 'Changed in both', 'Session' ); + $session->set( 'Deleted in $_SESSION, changed in Session', 'Session' ); + $session->remove( 'Deleted in Session' ); + $session->remove( 'Deleted in both' ); + $session->remove( 'Deleted in Session, changed in $_SESSION' ); + $session->save(); + $_SESSION['Added in $_SESSION'] = '$_SESSION'; + $_SESSION['Added in both'] = '$_SESSION'; + $_SESSION['Changed in $_SESSION'] = '$_SESSION'; + $_SESSION['Changed in both'] = '$_SESSION'; + $_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION'; + unset( $_SESSION['Deleted in $_SESSION'] ); + unset( $_SESSION['Deleted in both'] ); + unset( $_SESSION['Deleted in $_SESSION, changed in Session'] ); + session_write_close(); + + $this->assertEquals( array( + 'Added in Session' => 'Session', + 'Added in $_SESSION' => '$_SESSION', + 'Added in both' => 'Session', + 'Unchanged' => 'setup', + 'Changed in Session' => 'Session', + 'Changed in $_SESSION' => '$_SESSION', + 'Changed in both' => 'Session', + 'Deleted in Session, changed in $_SESSION' => '$_SESSION', + 'Deleted in $_SESSION, changed in Session' => 'Session', + ), iterator_to_array( $session ) ); + + $session->clear(); + $session->set( 42, 'forty-two' ); + $session->set( 'forty-two', 42 ); + $session->set( 'wrong', 43 ); + $session->persist(); + $session->save(); + + session_start(); + $this->assertArrayHasKey( 'forty-two', $_SESSION ); + $this->assertSame( 42, $_SESSION['forty-two'] ); + $this->assertArrayHasKey( 'wrong', $_SESSION ); + unset( $_SESSION['wrong'] ); + session_write_close(); + + $this->assertEquals( array( + 42 => 'forty-two', + 'forty-two' => 42, + ), iterator_to_array( $session ) ); + + // Test that write doesn't break if the session is invalid + $session = $manager->getEmptySession(); + $session->persist(); + session_id( $session->getId() ); + session_start(); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'SessionCheckInfo' => array( function ( &$reason ) { + $reason = 'Testing'; + return false; + } ), + ) ); + $this->assertNull( $manager->getSessionById( $session->getId(), true ), 'sanity check' ); + session_write_close(); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'SessionCheckInfo' => array(), + ) ); + $this->assertNotNull( $manager->getSessionById( $session->getId(), true ), 'sanity check' ); + } + + public static function provideHandlers() { + return array( + array( 'php' ), + array( 'php_binary' ), + array( 'php_serialize' ), + ); + } + + /** + * @dataProvider provideDisabled + * @expectedException BadMethodCallException + * @expectedExceptionMessage Attempt to use PHP session management + */ + public function testDisabled( $method, $args ) { + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock(); + \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' ); + $oldValue = $rProp->getValue(); + $rProp->setValue( $handler ); + $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $oldValue ) ); + + call_user_func_array( array( $handler, $method ), $args ); + } + + public static function provideDisabled() { + return array( + array( 'open', array( '', '' ) ), + array( 'read', array( '' ) ), + array( 'write', array( '', '' ) ), + array( 'destroy', array( '' ) ), + ); + } + + /** + * @dataProvider provideWrongInstance + * @expectedException UnexpectedValueException + * @expectedExceptionMessageRegExp /: Wrong instance called!$/ + */ + public function testWrongInstance( $method, $args ) { + $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock(); + \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' ); + + call_user_func_array( array( $handler, $method ), $args ); + } + + public static function provideWrongInstance() { + return array( + array( 'open', array( '', '' ) ), + array( 'close', array() ), + array( 'read', array( '' ) ), + array( 'write', array( '', '' ) ), + array( 'destroy', array( '' ) ), + array( 'gc', array( 0 ) ), + ); + } + +} diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php new file mode 100644 index 0000000000..d06706bf63 --- /dev/null +++ b/tests/phpunit/includes/session/SessionBackendTest.php @@ -0,0 +1,757 @@ +config ) { + $this->config = new \HashConfig(); + $this->manager = null; + } + if ( !$this->store ) { + $this->store = new TestBagOStuff(); + $this->manager = null; + } + + $logger = new \Psr\Log\NullLogger(); + if ( !$this->manager ) { + $this->manager = new SessionManager( array( + 'store' => $this->store, + 'logger' => $logger, + 'config' => $this->config, + ) ); + } + + if ( !$this->provider ) { + $this->provider = new \DummySessionProvider(); + } + $this->provider->setLogger( $logger ); + $this->provider->setConfig( $this->config ); + $this->provider->setManager( $this->manager ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $user ?: new User, true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->persist = false; + $priv->requests = array( 100 => new \FauxRequest() ); + $priv->usePhpSessionHandling = false; + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $manager->allSessionBackends = array( $backend->getId() => $backend ); + $manager->allSessionIds = array( $backend->getId() => $id ); + $manager->sessionProviders = array( (string)$this->provider => $this->provider ); + + return $backend; + } + + public function testConstructor() { + // Set variables + $this->getBackend(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', false ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $logger = new \Psr\Log\NullLogger(); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + "Refusing to create session for unverified user {$info->getUserInfo()}", + $ex->getMessage() + ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => self::SESSIONID, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( '!' . $info->getId() ); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'SessionId and SessionInfo don\'t match', + $ex->getMessage() + ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $id, $backend->getSessionId() ); + $this->assertSame( $this->provider, $backend->getProvider() ); + $this->assertInstanceOf( 'User', $backend->getUser() ); + $this->assertSame( 'UTSysop', $backend->getUser()->getName() ); + $this->assertSame( $info->wasPersisted(), $backend->isPersistent() ); + $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() ); + $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() ); + + $expire = time() + 100; + $this->store->setSessionMeta( self::SESSIONID, array( 'expires' => $expire ), 2 ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'forceHTTPS' => true, + 'metadata' => array( 'foo' ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $id, $backend->getSessionId() ); + $this->assertSame( $this->provider, $backend->getProvider() ); + $this->assertInstanceOf( 'User', $backend->getUser() ); + $this->assertTrue( $backend->getUser()->isAnon() ); + $this->assertSame( $info->wasPersisted(), $backend->isPersistent() ); + $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() ); + $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() ); + $this->assertSame( $expire, \TestingAccessWrapper::newFromObject( $backend )->expires ); + $this->assertSame( array( 'foo' ), $backend->getProviderMetadata() ); + } + + public function testSessionStuff() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->requests = array(); // Remove dummy session + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + + $request1 = new \FauxRequest(); + $session1 = $backend->getSession( $request1 ); + $request2 = new \FauxRequest(); + $session2 = $backend->getSession( $request2 ); + + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session1 ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session2 ); + $this->assertSame( 2, count( $priv->requests ) ); + + $index = \TestingAccessWrapper::newFromObject( $session1 )->index; + + $this->assertSame( $request1, $backend->getRequest( $index ) ); + $this->assertSame( null, $backend->suggestLoginUsername( $index ) ); + $request1->setCookie( 'UserName', 'Example' ); + $this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) ); + + $session1 = null; + $this->assertSame( 1, count( $priv->requests ) ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] ); + try { + $backend->getRequest( $index ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session index', $ex->getMessage() ); + } + try { + $backend->suggestLoginUsername( $index ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session index', $ex->getMessage() ); + } + + $session2 = null; + $this->assertSame( 0, count( $priv->requests ) ); + $this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds ); + } + + public function testResetId() { + $id = session_id(); + + $builder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'sessionIdWasReset' ) ); + + $this->provider = $builder->getMock(); + $this->provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( false ) ); + $this->provider->expects( $this->never() )->method( 'sessionIdWasReset' ); + $backend = $this->getBackend( User::newFromName( 'UTSysop' ) ); + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $sessionId = $backend->getSessionId(); + $backend->resetId(); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), $sessionId->getId() ); + $this->assertSame( $id, session_id() ); + $this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] ); + + $this->provider = $builder->getMock(); + $this->provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $backend = $this->getBackend(); + $this->provider->expects( $this->once() )->method( 'sessionIdWasReset' ) + ->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) ); + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $sessionId = $backend->getSessionId(); + $backend->resetId(); + $this->assertNotEquals( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), $sessionId->getId() ); + $this->assertInternalType( 'array', $this->store->getSession( $backend->getId() ) ); + $this->assertFalse( $this->store->getSession( self::SESSIONID ) ); + $this->assertSame( $id, session_id() ); + $this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] ); + } + + public function testPersist() { + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->once() )->method( 'persistSession' ); + $backend = $this->getBackend(); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $backend->save(); // This one shouldn't call $provider->persistSession() + + $backend->persist(); + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + + $this->provider = null; + $backend = $this->getBackend(); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $wrap->persist = true; + $wrap->expires = 0; + $backend->persist(); + $this->assertNotEquals( 0, $wrap->expires ); + } + + public function testRememberUser() { + $backend = $this->getBackend(); + + $remembered = $backend->shouldRememberUser(); + $backend->setRememberUser( !$remembered ); + $this->assertNotEquals( $remembered, $backend->shouldRememberUser() ); + $backend->setRememberUser( $remembered ); + $this->assertEquals( $remembered, $backend->shouldRememberUser() ); + } + + public function testForceHTTPS() { + $backend = $this->getBackend(); + + $force = $backend->shouldForceHTTPS(); + $backend->setForceHTTPS( !$force ); + $this->assertNotEquals( $force, $backend->shouldForceHTTPS() ); + $backend->setForceHTTPS( $force ); + $this->assertEquals( $force, $backend->shouldForceHTTPS() ); + } + + public function testLoggedOutTimestamp() { + $backend = $this->getBackend(); + + $backend->setLoggedOutTimestamp( 42 ); + $this->assertSame( 42, $backend->getLoggedOutTimestamp() ); + $backend->setLoggedOutTimestamp( '123' ); + $this->assertSame( 123, $backend->getLoggedOutTimestamp() ); + } + + public function testSetUser() { + $user = User::newFromName( 'UTSysop' ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'canChangeUser' ) ); + $this->provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + $backend = $this->getBackend(); + $this->assertFalse( $backend->canSetUser() ); + try { + $backend->setUser( $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Cannot set user on this session; check $session->canSetUser() first', + $ex->getMessage() + ); + } + $this->assertNotSame( $user, $backend->getUser() ); + + $this->provider = null; + $backend = $this->getBackend(); + $this->assertTrue( $backend->canSetUser() ); + $this->assertNotSame( $user, $backend->getUser(), 'sanity check' ); + $backend->setUser( $user ); + $this->assertSame( $user, $backend->getUser() ); + } + + public function testDirty() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->dataDirty = false; + $backend->dirty(); + $this->assertTrue( $priv->dataDirty ); + } + + public function testGetData() { + $backend = $this->getBackend(); + $data = $backend->getData(); + $this->assertSame( array(), $data ); + $this->assertTrue( \TestingAccessWrapper::newFromObject( $backend )->dataDirty ); + $data['???'] = '!!!'; + $this->assertSame( array( '???' => '!!!' ), $data ); + + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend(); + $this->assertSame( $testData, $backend->getData() ); + $this->assertFalse( \TestingAccessWrapper::newFromObject( $backend )->dataDirty ); + } + + public function testAddData() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'foo' => 1 ) ); + $this->assertSame( array( 'foo' => 1 ), $priv->data ); + $this->assertFalse( $priv->dataDirty ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'foo' => '1' ) ); + $this->assertSame( array( 'foo' => '1' ), $priv->data ); + $this->assertTrue( $priv->dataDirty ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'bar' => 2 ) ); + $this->assertSame( array( 'foo' => 1, 'bar' => 2 ), $priv->data ); + $this->assertTrue( $priv->dataDirty ); + } + + public function testDelaySave() { + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->persist = true; + + // Saves happen normally when no delay is in effect + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' ); + + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' ); + + $delay = $backend->delaySave(); + + // Autosave doesn't happen when no delay is in effect + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertFalse( $this->onSessionMetadataCalled ); + + // Save still does happen when no delay is in effect + $priv->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + + // Save happens when delay is consumed + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + \ScopedCallback::consume( $delay ); + $this->assertTrue( $this->onSessionMetadataCalled ); + + // Test multiple delays + $delay1 = $backend->delaySave(); + $delay2 = $backend->delaySave(); + $delay3 = $backend->delaySave(); + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay3 ); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay1 ); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay2 ); + $this->assertTrue( $this->onSessionMetadataCalled ); + } + + public function testSave() { + $user = User::newFromName( 'UTSysop' ); + $this->store = new TestBagOStuff(); + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + + $neverHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) ); + $neverHook->expects( $this->never() )->method( 'onSessionMetadata' ); + + $neverProvider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $neverProvider->expects( $this->never() )->method( 'persistSession' ); + + // Not persistent or dirty + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + // Not persistent, but dirty + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Persistent, not dirty + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + // Persistent and dirty + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Not marked dirty, but dirty data + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match'; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Bad hook + $this->provider = null; + $mockHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) ); + $mockHook->expects( $this->any() )->method( 'onSessionMetadata' ) + ->will( $this->returnCallback( + function ( SessionBackend $backend, array &$metadata, array $requests ) { + $metadata['userId']++; + } + ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $mockHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $backend->dirty(); + try { + $backend->save(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'SessionMetadata hook changed metadata key "userId"', + $ex->getMessage() + ); + } + + // SessionManager::preventSessionsForUser + \TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = array( + $user->getName() => true, + ); + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + } + + public function testRenew() { + $user = User::newFromName( 'UTSysop' ); + $this->store = new TestBagOStuff(); + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + + // Not persistent + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->never() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $wrap->expires = 0; + $backend->renew(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + $this->assertNotEquals( 0, $wrap->expires ); + + // Persistent + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $wrap->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $wrap->expires = 0; + $backend->renew(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + $this->assertNotEquals( 0, $wrap->expires ); + + // Not persistent, not expiring + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->never() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $expires = time() + $wrap->lifetime + 100; + $wrap->expires = $expires; + $backend->renew(); + $this->assertFalse( $this->onSessionMetadataCalled ); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + $this->assertEquals( $expires, $wrap->expires ); + } + + public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) { + $this->onSessionMetadataCalled = true; + $metadata['???'] = '!!!'; + } + + public function testResetIdOfGlobalSession() { + if ( !PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( SessionManager::singleton() ); + } + if ( !PHPSessionHandler::isEnabled() ) { + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $resetHandler = new \ScopedCallback( function () use ( $handler ) { + session_write_close(); + $handler->enable = false; + } ); + $handler->enable = true; + } + + $backend = $this->getBackend( User::newFromName( 'UTSysop' ) ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true; + + TestUtils::setSessionManagerSingleton( $this->manager ); + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $request = \RequestContext::getMain()->getRequest(); + $manager->globalSession = $backend->getSession( $request ); + $manager->globalSessionRequest = $request; + + session_id( self::SESSIONID ); + \MediaWiki\quietCall( 'session_start' ); + $backend->resetId(); + $this->assertNotEquals( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), session_id() ); + session_write_close(); + + session_id( '' ); + $this->assertNotSame( $backend->getId(), session_id(), 'sanity check' ); + $backend->persist(); + $this->assertSame( $backend->getId(), session_id() ); + session_write_close(); + } + + public function testGetAllowedUserRights() { + $this->provider = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getAllowedUserRights' ) ) + ->getMock(); + $this->provider->expects( $this->any() )->method( 'getAllowedUserRights' ) + ->will( $this->returnValue( array( 'foo', 'bar' ) ) ); + + $backend = $this->getBackend(); + $this->assertSame( array( 'foo', 'bar' ), $backend->getAllowedUserRights() ); + } + +} diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php new file mode 100644 index 0000000000..2b06d971a6 --- /dev/null +++ b/tests/phpunit/includes/session/SessionIdTest.php @@ -0,0 +1,22 @@ +assertSame( 'foo', $id->getId() ); + $this->assertSame( 'foo', (string)$id ); + $id->setId( 'bar' ); + $this->assertSame( 'bar', $id->getId() ); + $this->assertSame( 'bar', (string)$id ); + } + +} diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php new file mode 100644 index 0000000000..b411f3c48d --- /dev/null +++ b/tests/phpunit/includes/session/SessionInfoTest.php @@ -0,0 +1,328 @@ +fail( 'Expected exception not thrown', 'priority < min' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' ); + } + + try { + new SessionInfo( SessionInfo::MAX_PRIORITY + 1, array() ); + $this->fail( 'Expected exception not thrown', 'priority > max' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => 'ABC?' ) ); + $this->fail( 'Expected exception not thrown', 'bad session ID' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'userInfo' => new \stdClass ) ); + $this->fail( 'Expected exception not thrown', 'bad userInfo' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array() ); + $this->fail( 'Expected exception not thrown', 'no provider, no id' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(), + 'no provider, no id' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'copyFrom' => new \stdClass ) ); + $this->fail( 'Expected exception not thrown', 'bad copyFrom' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid copyFrom', $ex->getMessage(), + 'bad copyFrom' ); + } + + $manager = new SessionManager(); + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) ) + ->getMockForAbstractClass(); + $provider->setManager( $manager ); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock' ) ); + + $provider2 = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) ) + ->getMockForAbstractClass(); + $provider2->setManager( $manager ); + $provider2->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock2' ) ); + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'userInfo' => $anonInfo, + 'metadata' => 'foo', + ) ); + $this->fail( 'Expected exception not thrown', 'bad metadata' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $anonInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $unverifiedUserInfo, + 'metadata' => array( 'Foo' ), + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertSame( array( 'Foo' ), $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $userInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $id = $manager->generateSessionId(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $anonInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo, + 'metadata' => array( 'Foo' ), + ) ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'remembered' => true, + 'userInfo' => $userInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'no provider' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + ) ); + $this->assertFalse( $info->wasRemembered(), 'no user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $anonInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'anonymous user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $unverifiedUserInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'unverified user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => false, + 'userInfo' => $userInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'specific override' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'idIsSafe' => true, + ) ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertTrue( $info->isIdSafe() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'forceHTTPS' => 1, + ) ); + $this->assertTrue( $info->forceHTTPS() ); + + $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id . 'A', + 'provider' => $provider, + 'userInfo' => $userInfo, + 'idIsSafe' => true, + 'persisted' => true, + 'remembered' => true, + 'forceHTTPS' => true, + 'metadata' => array( 'foo!' ), + ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array( + 'copyFrom' => $fromInfo, + ) ); + $this->assertSame( $id . 'A', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array( 'foo!' ), $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array( + 'id' => $id . 'X', + 'provider' => $provider2, + 'userInfo' => $unverifiedUserInfo, + 'idIsSafe' => false, + 'persisted' => false, + 'remembered' => false, + 'forceHTTPS' => false, + 'metadata' => null, + 'copyFrom' => $fromInfo, + ) ); + $this->assertSame( $id . 'X', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider2, $info->getProvider() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]null$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + } + + public function testCompare() { + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( 'id' => $id ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( 'id' => $id ) ); + + $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' ); + $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' ); + $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' ); + } +} diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php new file mode 100644 index 0000000000..b4687ba2a7 --- /dev/null +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -0,0 +1,1672 @@ +config = new \HashConfig( array( + 'LanguageCode' => 'en', + 'SessionCacheType' => 'testSessionStore', + 'ObjectCacheSessionExpiry' => 100, + 'SessionProviders' => array( + array( 'class' => 'DummySessionProvider' ), + ) + ) ); + $this->logger = new \TestLogger( false, function ( $m ) { + return substr( $m, 0, 15 ) === 'SessionBackend ' ? null : $m; + } ); + $this->store = new TestBagOStuff(); + + return new SessionManager( array( + 'config' => $this->config, + 'logger' => $this->logger, + 'store' => $this->store, + ) ); + } + + protected function objectCacheDef( $object ) { + return array( 'factory' => function () use ( $object ) { + return $object; + } ); + } + + public function testSingleton() { + $reset = TestUtils::setSessionManagerSingleton( null ); + + $singleton = SessionManager::singleton(); + $this->assertInstanceOf( 'MediaWiki\\Session\\SessionManager', $singleton ); + $this->assertSame( $singleton, SessionManager::singleton() ); + } + + public function testGetGlobalSession() { + $context = \RequestContext::getMain(); + + if ( !PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( SessionManager::singleton() ); + } + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $oldEnable = $handler->enable; + $reset[] = new \ScopedCallback( function () use ( $handler, $oldEnable ) { + if ( $handler->enable ) { + session_write_close(); + } + $handler->enable = $oldEnable; + } ); + $reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() ); + + $handler->enable = true; + $request = new \FauxRequest(); + $context->setRequest( $request ); + $id = $request->getSession()->getId(); + + session_id( '' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + + session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() ); + $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() ); + + session_write_close(); + $handler->enable = false; + $request = new \FauxRequest(); + $context->setRequest( $request ); + $id = $request->getSession()->getId(); + + session_id( '' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + + session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + $this->assertSame( $id, $request->getSession()->getId() ); + } + + public function testConstructor() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $this->assertSame( $this->config, $manager->config ); + $this->assertSame( $this->logger, $manager->logger ); + $this->assertSame( $this->store, $manager->store ); + + $manager = \TestingAccessWrapper::newFromObject( new SessionManager() ); + $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config ); + + $manager = \TestingAccessWrapper::newFromObject( new SessionManager( array( + 'config' => $this->config, + ) ) ); + $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store ); + + foreach ( array( + 'config' => '$options[\'config\'] must be an instance of Config', + 'logger' => '$options[\'logger\'] must be an instance of LoggerInterface', + 'store' => '$options[\'store\'] must be an instance of BagOStuff', + ) as $key => $error ) { + try { + new SessionManager( array( $key => new \stdClass ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( $error, $ex->getMessage() ); + } + } + } + + public function testGetSessionForRequest() { + $manager = $this->getManager(); + $request = new \FauxRequest(); + + $id1 = ''; + $id2 = ''; + $idEmpty = 'empty-session-------------------'; + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( + array( 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe' ) + ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->any() )->method( 'provideSessionInfo' ) + ->with( $this->identicalTo( $request ) ) + ->will( $this->returnCallback( function ( $request ) { + return $request->info1; + } ) ); + $provider1->expects( $this->any() )->method( 'newSessionInfo' ) + ->will( $this->returnCallback( function () use ( $idEmpty, $provider1 ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $idEmpty, + 'persisted' => true, + 'idIsSafe' => true, + ) ); + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Provider1' ) ); + $provider1->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( '#1 sessions' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->any() )->method( 'provideSessionInfo' ) + ->with( $this->identicalTo( $request ) ) + ->will( $this->returnCallback( function ( $request ) { + return $request->info2; + } ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Provider2' ) ); + $provider2->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( '#2 sessions' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + // No provider returns info + $request->info1 = null; + $request->info2 = null; + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $idEmpty, $session->getId() ); + $this->assertNull( $manager->getPersistedSessionId( $request ) ); + + // Both providers return info, picks best one + $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id2, $session->getId() ); + $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) ); + + $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id1, $session->getId() ); + $this->assertSame( $id1, $manager->getPersistedSessionId( $request ) ); + + // Tied priorities + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + try { + $manager->getSessionForRequest( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \OverFlowException $ex ) { + $this->assertStringStartsWith( + 'Multiple sessions for this request tied for top priority: ', + $ex->getMessage() + ); + $this->assertCount( 2, $ex->sessionInfos ); + $this->assertContains( $request->info1, $ex->sessionInfos ); + $this->assertContains( $request->info2, $ex->sessionInfos ); + } + try { + $manager->getPersistedSessionId( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \OverFlowException $ex ) { + $this->assertStringStartsWith( + 'Multiple sessions for this request tied for top priority: ', + $ex->getMessage() + ); + $this->assertCount( 2, $ex->sessionInfos ); + $this->assertContains( $request->info1, $ex->sessionInfos ); + $this->assertContains( $request->info2, $ex->sessionInfos ); + } + + // Bad provider + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = null; + try { + $manager->getSessionForRequest( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Provider1 returned session info for a different provider: ' . $request->info1, + $ex->getMessage() + ); + } + try { + $manager->getPersistedSessionId( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Provider1 returned session info for a different provider: ' . $request->info1, + $ex->getMessage() + ); + } + + // Unusable session info + $this->logger->setCollect( true ); + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', false ), + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id2, $session->getId() ); + $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) ); + $this->logger->setCollect( false ); + + // Unpersisted session ID + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => false, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $request->info2 = null; + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id1, $session->getId() ); + $session->persist(); + $this->assertTrue( $session->isPersistent(), 'sanity check' ); + $this->assertNull( $manager->getPersistedSessionId( $request ) ); + } + + public function testGetSessionById() { + $manager = $this->getManager(); + + try { + $manager->getSessionById( 'bad' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage() ); + } + + // Unknown session ID + $id = $manager->generateSessionId(); + $session = $manager->getSessionById( $id, true ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id, $session->getId() ); + + $id = $manager->generateSessionId(); + $this->assertNull( $manager->getSessionById( $id, false ) ); + + // Known but unloadable session ID + $this->logger->setCollect( true ); + $id = $manager->generateSessionId(); + $this->store->setSession( $id, array( 'metadata' => array( + 'userId' => User::idFromName( 'UTSysop' ), + 'userToken' => 'bad', + ) ) ); + + $this->assertNull( $manager->getSessionById( $id, true ) ); + $this->assertNull( $manager->getSessionById( $id, false ) ); + $this->logger->setCollect( false ); + + // Known session ID + $this->store->setSession( $id, array() ); + $session = $manager->getSessionById( $id, false ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id, $session->getId() ); + } + + public function testGetEmptySession() { + $manager = $this->getManager(); + $pmanager = \TestingAccessWrapper::newFromObject( $manager ); + $request = new \FauxRequest(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'provideSessionInfo', 'newSessionInfo', '__toString' ) ); + + $expectId = null; + $info1 = null; + $info2 = null; + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnValue( null ) ); + $provider1->expects( $this->any() )->method( 'newSessionInfo' ) + ->with( $this->callback( function ( $id ) use ( &$expectId ) { + return $id === $expectId; + } ) ) + ->will( $this->returnCallback( function () use ( &$info1 ) { + return $info1; + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnValue( null ) ); + $provider2->expects( $this->any() )->method( 'newSessionInfo' ) + ->with( $this->callback( function ( $id ) use ( &$expectId ) { + return $id === $expectId; + } ) ) + ->will( $this->returnCallback( function () use ( &$info2 ) { + return $info2; + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + // No info + $expectId = null; + $info1 = null; + $info2 = null; + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'No provider could provide an empty session!', + $ex->getMessage() + ); + } + + // Info + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => 'empty---------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty---------------------------', $session->getId() ); + + // Info, explicitly + $expectId = 'expected------------------------'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $expectId, + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + $session = $pmanager->getEmptySessionInternal( null, $expectId ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $expectId, $session->getId() ); + + // Wrong ID + $expectId = 'expected-----------------------2'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => "un$expectId", + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned empty session info with a wrong id: ' . + "un$expectId != $expectId", + $ex->getMessage() + ); + } + + // Unsafe ID + $expectId = 'expected-----------------------2'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $expectId, + 'persisted' => true, + ) ); + $info2 = null; + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned empty session info with id flagged unsafe', + $ex->getMessage() + ); + } + + // Wrong provider + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty---------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned an empty session info for a different provider: ' . $info1, + $ex->getMessage() + ); + } + + // Highest priority wins + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty1--------------------------', $session->getId() ); + + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty2--------------------------', $session->getId() ); + + // Tied priorities throw an exception + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertStringStartsWith( + 'Multiple empty sessions tied for top priority: ', + $ex->getMessage() + ); + } + + // Bad id + try { + $pmanager->getEmptySessionInternal( null, 'bad' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage() ); + } + + // Session already exists + $expectId = 'expected-----------------------3'; + $this->store->setSessionMeta( $expectId, array( + 'provider' => 'MockProvider2', + 'userId' => 0, + 'userName' => null, + 'userToken' => null, + ) ); + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Session ID already exists', $ex->getMessage() ); + } + } + + public function testGetVaryHeaders() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getVaryHeaders', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'getVaryHeaders' ) + ->will( $this->returnValue( array( + 'Foo' => null, + 'Bar' => array( 'X', 'Bar1' ), + 'Quux' => null, + ) ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->once() )->method( 'getVaryHeaders' ) + ->will( $this->returnValue( array( + 'Baz' => null, + 'Bar' => array( 'X', 'Bar2' ), + 'Quux' => array( 'Quux' ), + ) ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + $expect = array( + 'Foo' => array(), + 'Bar' => array( 'X', 'Bar1', 3 => 'Bar2' ), + 'Quux' => array( 'Quux' ), + 'Baz' => array(), + 'Quux' => array( 'Quux' ), + ); + + $this->assertEquals( $expect, $manager->getVaryHeaders() ); + + // Again, to ensure it's cached + $this->assertEquals( $expect, $manager->getVaryHeaders() ); + } + + public function testGetVaryCookies() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getVaryCookies', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'getVaryCookies' ) + ->will( $this->returnValue( array( 'Foo', 'Bar' ) ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->once() )->method( 'getVaryCookies' ) + ->will( $this->returnValue( array( 'Foo', 'Baz' ) ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + $expect = array( 'Foo', 'Bar', 'Baz' ); + + $this->assertEquals( $expect, $manager->getVaryCookies() ); + + // Again, to ensure it's cached + $this->assertEquals( $expect, $manager->getVaryCookies() ); + } + + public function testGetProviders() { + $realManager = $this->getManager(); + $manager = \TestingAccessWrapper::newFromObject( $realManager ); + + $this->config->set( 'SessionProviders', array( + array( 'class' => 'DummySessionProvider' ), + ) ); + $providers = $manager->getProviders(); + $this->assertArrayHasKey( 'DummySessionProvider', $providers ); + $provider = \TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] ); + $this->assertSame( $manager->logger, $provider->logger ); + $this->assertSame( $manager->config, $provider->config ); + $this->assertSame( $realManager, $provider->getManager() ); + + $this->config->set( 'SessionProviders', array( + array( 'class' => 'DummySessionProvider' ), + array( 'class' => 'DummySessionProvider' ), + ) ); + $manager->sessionProviders = null; + try { + $manager->getProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Duplicate provider name "DummySessionProvider"', + $ex->getMessage() + ); + } + } + + public function testShutdown() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $manager->setLogger( new \Psr\Log\NullLogger() ); + + $mock = $this->getMock( 'stdClass', array( 'save' ) ); + $mock->expects( $this->once() )->method( 'save' ); + + $manager->allSessionBackends = array( $mock ); + $manager->shutdown(); + } + + public function testGetSessionFromInfo() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $request = new \FauxRequest(); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $manager->getProvider( 'DummySessionProvider' ), + 'id' => $id, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + \TestingAccessWrapper::newFromObject( $info )->idIsSafe = true; + $session1 = \TestingAccessWrapper::newFromObject( + $manager->getSessionFromInfo( $info, $request ) + ); + $session2 = \TestingAccessWrapper::newFromObject( + $manager->getSessionFromInfo( $info, $request ) + ); + + $this->assertSame( $session1->backend, $session2->backend ); + $this->assertNotEquals( $session1->index, $session2->index ); + $this->assertSame( $session1->getSessionId(), $session2->getSessionId() ); + $this->assertSame( $id, $session1->getId() ); + + \TestingAccessWrapper::newFromObject( $info )->idIsSafe = false; + $session3 = $manager->getSessionFromInfo( $info, $request ); + $this->assertNotSame( $id, $session3->getId() ); + } + + public function testBackendRegistration() { + $manager = $this->getManager(); + + $session = $manager->getSessionForRequest( new \FauxRequest ); + $backend = \TestingAccessWrapper::newFromObject( $session )->backend; + $sessionId = $session->getSessionId(); + $id = (string)$sessionId; + + $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() ); + + $manager->changeBackendId( $backend ); + $this->assertSame( $sessionId, $session->getSessionId() ); + $this->assertNotEquals( $id, (string)$sessionId ); + $id = (string)$sessionId; + + $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() ); + + // Destruction of the session here causes the backend to be deregistered + $session = null; + + try { + $manager->changeBackendId( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend was not registered with this SessionManager', $ex->getMessage() + ); + } + + try { + $manager->deregisterSessionBackend( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend was not registered with this SessionManager', $ex->getMessage() + ); + } + + $session = $manager->getSessionById( $id, true ); + $this->assertSame( $sessionId, $session->getSessionId() ); + } + + public function testGenerateSessionId() { + $manager = $this->getManager(); + + $id = $manager->generateSessionId(); + $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" ); + } + + public function testAutoCreateUser() { + global $wgGroupPermissions; + + $that = $this; + + \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff(); + $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) ); + + $this->stashMwGlobals( array( 'wgGroupPermissions' ) ); + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + + // Replace the global singleton with one configured for testing + $manager = $this->getManager(); + $reset = TestUtils::setSessionManagerSingleton( $manager ); + + $logger = new \TestLogger( true, function ( $m ) { + if ( substr( $m, 0, 15 ) === 'SessionBackend ' ) { + // Don't care. + return null; + } + $m = str_replace( 'MediaWiki\Session\SessionManager::autoCreateUser: ', '', $m ); + $m = preg_replace( '/ - from: .*$/', ' - from: XXX', $m ); + return $m; + } ); + $manager->setLogger( $logger ); + + $session = SessionManager::getGlobalSession(); + + // Can't create an already-existing user + $user = User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( $id, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertSame( array(), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation works at all + $user = User::newFromName( 'UTSessionAutoCreate1' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate1', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate1', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate1) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Check lack of permissions + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Check other permission + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = true; + $user = User::newFromName( 'UTSessionAutoCreate2' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate2', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate2', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate2) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test account-creation block + $anon = new User; + $block = new \Block( array( + 'address' => $anon->getName(), + 'user' => $id, + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ) ); + $block->insert(); + $this->assertInstanceOf( 'Block', $anon->isBlockedFromCreateAccount(), 'sanity check' ); + $reset2 = new \ScopedCallback( array( $block, 'delete' ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + \ScopedCallback::consume( $reset2 ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation still works + $user = User::newFromName( 'UTSessionAutoCreate3' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate3', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate3', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate3) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by AuthPlugin + global $wgAuth; + $oldWgAuth = $wgAuth; + $mockWgAuth = $this->getMock( 'AuthPlugin', array( 'autoCreate' ) ); + $mockWgAuth->expects( $this->once() )->method( 'autoCreate' ) + ->will( $this->returnValue( false ) ); + $this->setMwGlobals( array( + 'wgAuth' => $mockWgAuth, + ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->setMwGlobals( array( + 'wgAuth' => $oldWgAuth, + ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by AuthPlugin' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by wfReadOnly() + $this->setMwGlobals( array( + 'wgReadOnly' => 'Because', + ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->setMwGlobals( array( + 'wgReadOnly' => false, + ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by wfReadOnly()' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by a previous session + $session->set( 'MWSession::AutoCreateBlacklist', 'test' ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'blacklisted in session (test)' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test uncreatable name + $user = User::newFromName( 'UTDoesNotExist@' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist@', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'Invalid username, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test AbortAutoAccount hook + $mock = $this->getMock( __CLASS__, array( 'onAbortAutoAccount' ) ); + $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) + ->will( $this->returnCallback( function ( User $user, &$msg ) { + $msg = 'No way!'; + return false; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by hook: No way!' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test AbortAutoAccount hook screwing up the name + $mock = $this->getMock( 'stdClass', array( 'onAbortAutoAccount' ) ); + $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) + ->will( $this->returnCallback( function ( User $user ) { + $user->setName( 'UTDoesNotExistEither' ); + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) ); + try { + $user = User::newFromName( 'UTDoesNotExist' ); + $manager->autoCreateUser( $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'AbortAutoAccount hook tried to change the user name', + $ex->getMessage() + ); + } + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertNotSame( 'UTDoesNotExistEither', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExistEither', User::READ_LATEST ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) ); + $session->clear(); + $this->assertSame( array(), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test for "exception backoff" + $user = User::newFromName( 'UTDoesNotExist' ); + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $user->getName() ) ); + $cache->set( $backoffKey, 1, 60 * 10 ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $cache->delete( $backoffKey ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by prior creation attempt failures' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation still works, and test completion hook + $cb = $this->callback( function ( User $user ) use ( $that ) { + $that->assertNotEquals( 0, $user->getId() ); + $that->assertSame( 'UTSessionAutoCreate4', $user->getName() ); + $that->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) + ); + return true; + } ); + $mock = $this->getMock( 'stdClass', + array( 'onAuthPluginAutoCreate', 'onLocalUserCreated' ) ); + $mock->expects( $this->once() )->method( 'onAuthPluginAutoCreate' ) + ->with( $cb ); + $mock->expects( $this->once() )->method( 'onLocalUserCreated' ) + ->with( $cb, $this->identicalTo( true ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'AuthPluginAutoCreate' => array( $mock ), + 'LocalUserCreated' => array( $mock ), + ) ); + $user = User::newFromName( 'UTSessionAutoCreate4' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate4', $user->getName() ); + $this->assertEquals( + $user->getId(), + User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) + ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'AuthPluginAutoCreate' => array(), + 'LocalUserCreated' => array(), + ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate4) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + + public function onAbortAutoAccount( User $user, &$msg ) { + } + + public function testPreventSessionsForUser() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'preventSessionsForUser', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'preventSessionsForUser' ) + ->with( $this->equalTo( 'UTSysop' ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + ) ); + + $user = User::newFromName( 'UTSysop' ); + $token = $user->getToken( true ); + + $this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) ); + $manager->preventSessionsForUser( 'UTSysop' ); + $this->assertNotEquals( $token, User::newFromName( 'UTSysop' )->getToken() ); + $this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) ); + } + + public function testLoadSessionInfoFromStore() { + $manager = $this->getManager(); + $logger = new \TestLogger( true, function ( $m ) { + return preg_replace( + '/^Session \[\d+\]\w+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m + ); + } ); + $manager->setLogger( $logger ); + $request = new \FauxRequest(); + + // TestingAccessWrapper can't handle methods with reference arguments, sigh. + $rClass = new \ReflectionClass( $manager ); + $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' ); + $rMethod->setAccessible( true ); + $loadSessionInfoFromStore = function ( &$info ) use ( $rMethod, $manager, $request ) { + return $rMethod->invokeArgs( $manager, array( &$info, $request ) ); + }; + + $userInfo = UserInfo::newFromName( 'UTSysop', true ); + $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false ); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $metadata = array( + 'userId' => $userInfo->getId(), + 'userName' => $userInfo->getName(), + 'userToken' => $userInfo->getToken( true ), + 'provider' => 'Mock', + ); + + $builder = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( '__toString', 'mergeMetadata', 'refreshSessionInfo' ) ); + + $provider = $builder->getMockForAbstractClass(); + $provider->setManager( $manager ); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'refreshSessionInfo' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock' ) ); + $provider->expects( $this->any() )->method( 'mergeMetadata' ) + ->will( $this->returnCallback( function ( $a, $b ) { + if ( $b === array( 'Throw' ) ) { + throw new \UnexpectedValueException( 'no merge!' ); + } + return array( 'Merged' ); + } ) ); + + $provider2 = $builder->getMockForAbstractClass(); + $provider2->setManager( $manager ); + $provider2->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( false ) ); + $provider2->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock2' ) ); + $provider2->expects( $this->any() )->method( 'refreshSessionInfo' ) + ->will( $this->returnCallback( function ( $info, $request, &$metadata ) { + $metadata['changed'] = true; + return true; + } ) ); + + $provider3 = $builder->getMockForAbstractClass(); + $provider3->setManager( $manager ); + $provider3->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider3->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider3->expects( $this->once() )->method( 'refreshSessionInfo' ) + ->will( $this->returnValue( false ) ); + $provider3->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock3' ) ); + + \TestingAccessWrapper::newFromObject( $manager )->sessionProviders = array( + (string)$provider => $provider, + (string)$provider2 => $provider2, + (string)$provider3 => $provider3, + ); + + // No metadata, basic usage + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user, no metadata + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Unverified user provided and no metadata to auth it' ) + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // No metadata, missing data + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Null provider and no metadata' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertInstanceOf( 'MediaWiki\\Session\\UserInfo', $info->getUserInfo() ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertTrue( $info->getUserInfo()->isAnon() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'Session X: No user provided and provider cannot set user' ) + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Incomplete/bad metadata + $this->store->setRawSession( $id, true ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->deleteSession( $id ); + $this->store->setRawSession( $id, array( 'metadata' => $metadata ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => true ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'metadata' => true, 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + foreach ( $metadata as $key => $dummy ) { + $tmp = $metadata; + unset( $tmp[$key] ); + $this->store->setRawSession( $id, array( 'metadata' => $tmp, 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad metadata' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + + // Basic usage with metadata + $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => array() ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Mismatched provider + $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Wrong provider, Bad !== Mock' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Unknown provider + $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Unknown provider, Bad' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Fill in provider + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Bad user metadata + $this->store->setSessionMeta( $id, array( 'userId' => -1, 'userToken' => null ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::ERROR, 'Session X: Invalid ID' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => '', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::ERROR, 'Session X: Invalid user name' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched user by ID + $this->store->setSessionMeta( + $id, array( 'userId' => $userInfo->getId() + 1, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User ID mismatch, 2 !== 1' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched user by name + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => 'X', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User name mismatch, X !== UTSysop' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // ID matches, name doesn't + $this->store->setSessionMeta( + $id, array( 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( + LogLevel::WARNING, 'Session X: User ID matched but name didn\'t (rename?), X !== UTSysop' + ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched anon user + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( + LogLevel::WARNING, 'Session X: Metadata has an anonymous user, but a non-anon user was provided' + ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Lookup user by ID + $this->store->setSessionMeta( $id, array( 'userToken' => null ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Lookup user by name + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Lookup anonymous user + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isAnon() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user with metadata + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user with metadata + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Wrong token + $this->store->setSessionMeta( $id, array( 'userToken' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User token mismatch' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Provider metadata + $this->store->setSessionMeta( $id, array( 'provider' => 'Mock2' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Info' ), + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Info', 'changed' => true ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'providerMetadata' => array( 'Saved' ) ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Saved' ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Info' ), + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Merged' ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Throw' ), + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Metadata merge failed: no merge!' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Remember from session + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'remember' => true ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'remember' => false ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // forceHTTPS from session + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'forceHTTPS' => true ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'forceHTTPS' => false ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'forceHTTPS' => true + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Provider refreshSessionInfo() returning false + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider3, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Hook + $that = $this; + $called = false; + $data = array( 'foo' => 1 ); + $this->store->setSession( $id, array( 'metadata' => $metadata, 'data' => $data ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'SessionCheckInfo' => array( function ( &$reason, $i, $r, $m, $d ) use ( + $that, $info, $metadata, $data, $request, &$called + ) { + $that->assertSame( $info->getId(), $i->getId() ); + $that->assertSame( $info->getProvider(), $i->getProvider() ); + $that->assertSame( $info->getUserInfo(), $i->getUserInfo() ); + $that->assertSame( $request, $r ); + $that->assertEquals( $metadata, $m ); + $that->assertEquals( $data, $d ); + $called = true; + return false; + } ) + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $called ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Hook aborted' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + +} diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php new file mode 100644 index 0000000000..d7aebcd6b3 --- /dev/null +++ b/tests/phpunit/includes/session/SessionProviderTest.php @@ -0,0 +1,195 @@ +getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + $provider->setConfig( $config ); + $this->assertSame( $config, $priv->config ); + $provider->setLogger( $logger ); + $this->assertSame( $logger, $priv->logger ); + $provider->setManager( $manager ); + $this->assertSame( $manager, $priv->manager ); + $this->assertSame( $manager, $provider->getManager() ); + + $this->assertSame( array(), $provider->getVaryHeaders() ); + $this->assertSame( array(), $provider->getVaryCookies() ); + $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) ); + + $this->assertSame( get_class( $provider ), (string)$provider ); + + $this->assertNull( $provider->whyNoSession() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'provider' => $provider, + ) ); + $metadata = array( 'foo' ); + $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) ); + $this->assertSame( array( 'foo' ), $metadata ); + } + + /** + * @dataProvider provideNewSessionInfo + * @param bool $persistId Return value for ->persistsSessionId() + * @param bool $persistUser Return value for ->persistsSessionUser() + * @param bool $ok Whether a SessionInfo is provided + */ + public function testNewSessionInfo( $persistId, $persistUser, $ok ) { + $manager = new SessionManager(); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( $persistId ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $persistUser ) ); + $provider->setManager( $manager ); + + if ( $ok ) { + $info = $provider->newSessionInfo(); + $this->assertNotNull( $info ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info = $provider->newSessionInfo( $id ); + $this->assertNotNull( $info ); + $this->assertSame( $id, $info->getId() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + } else { + $this->assertNull( $provider->newSessionInfo() ); + } + } + + public function testMergeMetadata() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->getMockForAbstractClass(); + + try { + $provider->mergeMetadata( + array( 'foo' => 1, 'baz' => 3 ), + array( 'bar' => 2, 'baz' => '3' ) + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'Key "baz" changed', $ex->getMessage() ); + } + + $res = $provider->mergeMetadata( + array( 'foo' => 1, 'baz' => 3 ), + array( 'bar' => 2, 'baz' => 3 ) + ); + $this->assertSame( array( 'bar' => 2, 'baz' => 3 ), $res ); + } + + public static function provideNewSessionInfo() { + return array( + array( false, false, false ), + array( true, false, false ), + array( false, true, false ), + array( true, true, true ), + ); + } + + public function testImmutableSessions() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->preventSessionsForUser( 'Foo' ); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + try { + $provider->preventSessionsForUser( 'Foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + } + + public function testHashToSessionId() { + $config = new \HashConfig( array( + 'SecretKey' => 'Shhh!', + ) ); + + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider', + array(), 'MockSessionProvider' ); + $provider->setConfig( $config ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) ); + $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9', + $priv->hashToSessionId( 'foobar', 'secret' ) ); + + try { + $priv->hashToSessionId( array() ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$data must be a string, array was passed', + $ex->getMessage() + ); + } + try { + $priv->hashToSessionId( '', false ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$key must be a string or null, boolean was passed', + $ex->getMessage() + ); + } + } + + public function testDescribe() { + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider', + array(), 'MockSessionProvider' ); + + $this->assertSame( + 'MockSessionProvider sessions', + $provider->describe( \Language::factory( 'en' ) ) + ); + } + + public function testGetAllowedUserRights() { + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' ); + $backend = TestUtils::getDummySessionBackend(); + + try { + $provider->getAllowedUserRights( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend\'s provider isn\'t $this', + $ex->getMessage() + ); + } + + \TestingAccessWrapper::newFromObject( $backend )->provider = $provider; + $this->assertNull( $provider->getAllowedUserRights( $backend ) ); + } + +} diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php new file mode 100644 index 0000000000..efc92f7ff7 --- /dev/null +++ b/tests/phpunit/includes/session/SessionTest.php @@ -0,0 +1,202 @@ +requests = array( -1 => 'dummy' ); + \TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' ); + + $session = new Session( $backend, 42 ); + $priv = \TestingAccessWrapper::newFromObject( $session ); + $this->assertSame( $backend, $priv->backend ); + $this->assertSame( 42, $priv->index ); + + $request = new \FauxRequest(); + $priv2 = \TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) ); + $this->assertSame( $backend, $priv2->backend ); + $this->assertNotSame( $priv->index, $priv2->index ); + $this->assertSame( $request, $priv2->getRequest() ); + } + + /** + * @dataProvider provideMethods + * @param string $m Method to test + * @param array $args Arguments to pass to the method + * @param bool $index Whether the backend method gets passed the index + * @param bool $ret Whether the method returns a value + */ + public function testMethods( $m, $args, $index, $ret ) { + $mock = $this->getMock( 'MediaWiki\\Session\\DummySessionBackend', + array( $m, 'deregisterSession' ) ); + $mock->expects( $this->once() )->method( 'deregisterSession' ) + ->with( $this->identicalTo( 42 ) ); + + $tmp = $mock->expects( $this->once() )->method( $m ); + $expectArgs = array(); + if ( $index ) { + $expectArgs[] = $this->identicalTo( 42 ); + } + foreach ( $args as $arg ) { + $expectArgs[] = $this->identicalTo( $arg ); + } + $tmp = call_user_func_array( array( $tmp, 'with' ), $expectArgs ); + + $retval = new \stdClass; + $tmp->will( $this->returnValue( $retval ) ); + + $session = TestUtils::getDummySession( $mock, 42 ); + + if ( $ret ) { + $this->assertSame( $retval, call_user_func_array( array( $session, $m ), $args ) ); + } else { + $this->assertNull( call_user_func_array( array( $session, $m ), $args ) ); + } + + // Trigger Session destructor + $session = null; + } + + public static function provideMethods() { + return array( + array( 'getId', array(), false, true ), + array( 'getSessionId', array(), false, true ), + array( 'resetId', array(), false, true ), + array( 'getProvider', array(), false, true ), + array( 'isPersistent', array(), false, true ), + array( 'persist', array(), false, false ), + array( 'shouldRememberUser', array(), false, true ), + array( 'setRememberUser', array( true ), false, false ), + array( 'getRequest', array(), true, true ), + array( 'getUser', array(), false, true ), + array( 'getAllowedUserRights', array(), false, true ), + array( 'canSetUser', array(), false, true ), + array( 'setUser', array( new \stdClass ), false, false ), + array( 'suggestLoginUsername', array(), true, true ), + array( 'shouldForceHTTPS', array(), false, true ), + array( 'setForceHTTPS', array( true ), false, false ), + array( 'getLoggedOutTimestamp', array(), false, true ), + array( 'setLoggedOutTimestamp', array( 123 ), false, false ), + array( 'getProviderMetadata', array(), false, true ), + array( 'save', array(), false, false ), + array( 'delaySave', array(), false, true ), + array( 'renew', array(), false, false ), + ); + } + + public function testDataAccess() { + $session = TestUtils::getDummySession(); + $backend = \TestingAccessWrapper::newFromObject( $session )->backend; + + $this->assertEquals( 1, $session->get( 'foo' ) ); + $this->assertEquals( 'zero', $session->get( 0 ) ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( null, $session->get( 'null' ) ); + $this->assertEquals( 'default', $session->get( 'null', 'default' ) ); + $this->assertFalse( $backend->dirty ); + + $session->set( 'foo', 55 ); + $this->assertEquals( 55, $backend->data['foo'] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertEquals( 'one', $backend->data[1] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertFalse( $backend->dirty ); + + $this->assertTrue( $session->exists( 'foo' ) ); + $this->assertTrue( $session->exists( 1 ) ); + $this->assertFalse( $session->exists( 'null' ) ); + $this->assertFalse( $session->exists( 100 ) ); + $this->assertFalse( $backend->dirty ); + + $session->remove( 'foo' ); + $this->assertArrayNotHasKey( 'foo', $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + $session->remove( 1 ); + $this->assertArrayNotHasKey( 1, $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->remove( 101 ); + $this->assertFalse( $backend->dirty ); + + $backend->data = array( 'a', 'b', '?' => 'c' ); + $this->assertSame( 3, $session->count() ); + $this->assertSame( 3, count( $session ) ); + $this->assertFalse( $backend->dirty ); + + $data = array(); + foreach ( $session as $key => $value ) { + $data[$key] = $value; + } + $this->assertEquals( $backend->data, $data ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( $backend->data, iterator_to_array( $session ) ); + $this->assertFalse( $backend->dirty ); + } + + public function testClear() { + $session = TestUtils::getDummySession(); + $priv = \TestingAccessWrapper::newFromObject( $session ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( array(), $backend->data ); + $this->assertTrue( $backend->dirty ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->data = array(); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertFalse( $backend->dirty ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( false ) ); + $backend->expects( $this->never() )->method( 'setUser' ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( array(), $backend->data ); + $this->assertTrue( $backend->dirty ); + } + +} diff --git a/tests/phpunit/includes/session/TestBagOStuff.php b/tests/phpunit/includes/session/TestBagOStuff.php new file mode 100644 index 0000000000..e674e7bce0 --- /dev/null +++ b/tests/phpunit/includes/session/TestBagOStuff.php @@ -0,0 +1,78 @@ +setSession( $id, array( 'data' => $data ), $expiry, $user ); + } + + /** + * @param string $id Session ID + * @param array $metadata Session metadata + * @param int $expiry Expiry + */ + public function setSessionMeta( $id, array $metadata, $expiry = 0 ) { + $this->setSession( $id, array( 'metadata' => $metadata ), $expiry ); + } + + /** + * @param string $id Session ID + * @param array $blob Session metadata and data + * @param int $expiry Expiry + * @param User $user User for metadata + */ + public function setSession( $id, array $blob, $expiry = 0, User $user = null ) { + $blob += array( + 'data' => array(), + 'metadata' => array(), + ); + $blob['metadata'] += array( + 'userId' => $user ? $user->getId() : 0, + 'userName' => $user ? $user->getName() : null, + 'userToken' => $user ? $user->getToken( true ) : null, + 'provider' => 'DummySessionProvider', + ); + + $this->setRawSession( $id, $blob, $expiry, $user ); + } + + /** + * @param string $id Session ID + * @param array|mixed $blob Session metadata and data + * @param int $expiry Expiry + */ + public function setRawSession( $id, $blob, $expiry = 0 ) { + if ( $expiry <= 0 ) { + $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' ); + } + + $this->set( wfMemcKey( 'MWSession', $id ), $blob, $expiry ); + } + + /** + * @param string $id Session ID + * @return mixed + */ + public function getSession( $id ) { + return $this->get( wfMemcKey( 'MWSession', $id ) ); + } + + /** + * @param string $id Session ID + */ + public function deleteSession( $id ) { + $this->delete( wfMemcKey( 'MWSession', $id ) ); + } + +} diff --git a/tests/phpunit/includes/session/TestUtils.php b/tests/phpunit/includes/session/TestUtils.php new file mode 100644 index 0000000000..16199837ee --- /dev/null +++ b/tests/phpunit/includes/session/TestUtils.php @@ -0,0 +1,99 @@ +setAccessible( true ); + $rGlobalSession = new \ReflectionProperty( + 'MediaWiki\\Session\\SessionManager', 'globalSession' + ); + $rGlobalSession->setAccessible( true ); + $rGlobalSessionRequest = new \ReflectionProperty( + 'MediaWiki\\Session\\SessionManager', 'globalSessionRequest' + ); + $rGlobalSessionRequest->setAccessible( true ); + + $oldInstance = $rInstance->getValue(); + + $reset = array( + array( $rInstance, $oldInstance ), + array( $rGlobalSession, $rGlobalSession->getValue() ), + array( $rGlobalSessionRequest, $rGlobalSessionRequest->getValue() ), + ); + + $rInstance->setValue( $manager ); + $rGlobalSession->setValue( null ); + $rGlobalSessionRequest->setValue( null ); + if ( $manager && PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( $manager ); + } + + return new \ScopedCallback( function () use ( &$reset, $oldInstance ) { + foreach ( $reset as &$arr ) { + $arr[0]->setValue( $arr[1] ); + } + if ( $oldInstance && PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( $oldInstance ); + } + } ); + } + + /** + * If you need a SessionBackend for testing but don't want to create a real + * one, use this. + * @return SessionBackend Unconfigured! Use reflection to set any private + * fields necessary. + */ + public static function getDummySessionBackend() { + $rc = new \ReflectionClass( 'MediaWiki\\Session\\SessionBackend' ); + if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) { + \PHPUnit_Framework_Assert::markTestSkipped( + 'ReflectionClass::newInstanceWithoutConstructor isn\'t available' + ); + } + + return $rc->newInstanceWithoutConstructor(); + } + + /** + * If you need a Session for testing but don't want to create a backend to + * construct one, use this. + * @param object $backend Object to serve as the SessionBackend + * @param int $index Index + * @return Session + */ + public static function getDummySession( $backend = null, $index = -1 ) { + $rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' ); + if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) { + \PHPUnit_Framework_Assert::markTestSkipped( + 'ReflectionClass::newInstanceWithoutConstructor isn\'t available' + ); + } + + if ( $backend === null ) { + $backend = new DummySessionBackend; + } + + $session = $rc->newInstanceWithoutConstructor(); + $priv = \TestingAccessWrapper::newFromObject( $session ); + $priv->backend = $backend; + $priv->index = $index; + return $session; + } + +} diff --git a/tests/phpunit/includes/session/UserInfoTest.php b/tests/phpunit/includes/session/UserInfoTest.php new file mode 100644 index 0000000000..121bb72a77 --- /dev/null +++ b/tests/phpunit/includes/session/UserInfoTest.php @@ -0,0 +1,186 @@ +assertTrue( $userinfo->isAnon() ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( 0, $userinfo->getId() ); + $this->assertSame( null, $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertNotNull( $userinfo->getUser() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + $this->assertSame( '', (string)$userinfo ); + } + + public function testNewFromId() { + $id = wfGetDB( DB_MASTER )->selectField( 'user', 'MAX(user_id)' ) + 1; + try { + UserInfo::newFromId( $id ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid ID', $ex->getMessage() ); + } + + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromId( $user->getId() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromId( $user->getId(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + } + + public function testNewFromName() { + try { + UserInfo::newFromName( '' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid user name', $ex->getMessage() ); + } + + // User name that exists + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromName( $user->getName() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromName( $user->getName(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // User name that does not exist should still be non-anon + $user = User::newFromName( 'DoesNotExist' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $userinfo = UserInfo::newFromName( $user->getName() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( null, $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromName( $user->getName(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + } + + public function testNewFromUser() { + // User that exists + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromUser( $user ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertSame( $user, $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertSame( $user, $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromUser( $user, true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // User name that does not exist should still be non-anon + $user = User::newFromName( 'DoesNotExist' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $userinfo = UserInfo::newFromUser( $user ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertSame( $user, $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( null, $userinfo2->getToken() ); + $this->assertSame( $user, $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromUser( $user, true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // Anonymous user gives anon + $userinfo = UserInfo::newFromUser( new User, false ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( 0, $userinfo->getId() ); + $this->assertSame( null, $userinfo->getName() ); + } + +} diff --git a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php new file mode 100644 index 0000000000..163c52d016 --- /dev/null +++ b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php @@ -0,0 +1,85 @@ + 3 ), + __METHOD__ + ); + + if ( $res === false || strpos( $res, '"sitename":"Wikidata"' ) === false ) { + $connectivity = false; + } else { + $connectivity = true; + } + } + + if ( !$connectivity ) { + $this->markTestSkipped( 'MediaWikiPageNameNormalizerTest needs internet connectivity.' ); + } + } + + /** + * @dataProvider normalizePageTitleProvider + */ + public function testNormalizePageTitle( $expected, $pageName ) { + $normalizer = new MediaWikiPageNameNormalizer(); + + $this->assertSame( + $expected, + $normalizer->normalizePageName( $pageName, 'https://www.wikidata.org/w/api.php' ) + ); + } + + public function normalizePageTitleProvider() { + // Note: This makes (very conservative) assumptions about pages on Wikidata + // existing or not. + return array( + 'universe (Q1)' => array( + 'Q1', 'Q1' + ), + 'Q404 redirects to Q395' => array( + 'Q395', 'Q404' + ), + 'there is no Q0' => array( + false, 'Q0' + ) + ); + } + +} diff --git a/tests/phpunit/includes/upload/UploadBaseTest.php b/tests/phpunit/includes/upload/UploadBaseTest.php index 9ec1b4667b..90051ee11b 100644 --- a/tests/phpunit/includes/upload/UploadBaseTest.php +++ b/tests/phpunit/includes/upload/UploadBaseTest.php @@ -125,7 +125,6 @@ class UploadBaseTest extends MediaWikiTestCase { ); } - /** * @dataProvider provideCheckSvgScriptCallback */ diff --git a/tests/phpunit/includes/upload/UploadFromUrlTest.php b/tests/phpunit/includes/upload/UploadFromUrlTest.php index b749662962..428fd27629 100644 --- a/tests/phpunit/includes/upload/UploadFromUrlTest.php +++ b/tests/phpunit/includes/upload/UploadFromUrlTest.php @@ -16,7 +16,6 @@ class UploadFromUrlTest extends ApiTestCase { 'wgAllowCopyUploads' => true, 'wgAllowAsyncCopyUploads' => true, ) ); - wfSetupSession(); if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) { $this->deleteFile( 'UploadFromUrlTest.png' ); @@ -26,15 +25,12 @@ class UploadFromUrlTest extends ApiTestCase { protected function doApiRequest( array $params, array $unused = null, $appendModule = false, User $user = null ) { - $sessionId = session_id(); - session_write_close(); + global $wgRequest; - $req = new FauxRequest( $params, true, $_SESSION ); + $req = new FauxRequest( $params, true, $wgRequest->getSession() ); $module = new ApiMain( $req, true ); $module->execute(); - wfSetupSession( $sessionId ); - return array( $module->getResult()->getResultData( null, array( 'Strip' => 'all' ) ), $req diff --git a/tests/phpunit/includes/user/BotPasswordTest.php b/tests/phpunit/includes/user/BotPasswordTest.php new file mode 100644 index 0000000000..c118803756 --- /dev/null +++ b/tests/phpunit/includes/user/BotPasswordTest.php @@ -0,0 +1,379 @@ +setMwGlobals( array( + 'wgEnableBotPasswords' => true, + 'wgBotPasswordsDatabase' => false, + 'wgCentralIdLookupProvider' => 'BotPasswordTest OkMock', + 'wgGrantPermissions' => array( + 'test' => array( 'read' => true ), + ), + 'wgUserrightsInterwikiDelimiter' => '@', + ) ); + + $mock1 = $this->getMockForAbstractClass( 'CentralIdLookup' ); + $mock1->expects( $this->any() )->method( 'isAttached' ) + ->will( $this->returnValue( true ) ); + $mock1->expects( $this->any() )->method( 'lookupUserNames' ) + ->will( $this->returnValue( array( 'UTSysop' => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ) ) ); + $mock1->expects( $this->never() )->method( 'lookupCentralIds' ); + + $mock2 = $this->getMockForAbstractClass( 'CentralIdLookup' ); + $mock2->expects( $this->any() )->method( 'isAttached' ) + ->will( $this->returnValue( false ) ); + $mock2->expects( $this->any() )->method( 'lookupUserNames' ) + ->will( $this->returnArgument( 0 ) ); + $mock2->expects( $this->never() )->method( 'lookupCentralIds' ); + + $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', array( + 'BotPasswordTest OkMock' => array( 'factory' => function () use ( $mock1 ) { + return $mock1; + } ), + 'BotPasswordTest FailMock' => array( 'factory' => function () use ( $mock2 ) { + return $mock2; + } ), + ) ); + + CentralIdLookup::resetCache(); + } + + public function addDBData() { + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' ); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( + 'bot_passwords', + array( 'bp_user' => array( 42, 43 ), 'bp_app_id' => 'BotPassword' ), + __METHOD__ + ); + $dbw->insert( + 'bot_passwords', + array( + array( + 'bp_user' => 42, + 'bp_app_id' => 'BotPassword', + 'bp_password' => $pwhash->toString(), + 'bp_token' => 'token!', + 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', + 'bp_grants' => '["test"]', + ), + array( + 'bp_user' => 43, + 'bp_app_id' => 'BotPassword', + 'bp_password' => $pwhash->toString(), + 'bp_token' => 'token!', + 'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}', + 'bp_grants' => '["test"]', + ), + ), + __METHOD__ + ); + } + + public function testBasics() { + $user = User::newFromName( 'UTSysop' ); + $bp = BotPassword::newFromUser( $user, 'BotPassword' ); + $this->assertInstanceOf( 'BotPassword', $bp ); + $this->assertTrue( $bp->isSaved() ); + $this->assertSame( 42, $bp->getUserCentralId() ); + $this->assertSame( 'BotPassword', $bp->getAppId() ); + $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) ); + $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() ); + $this->assertSame( array( 'test' ), $bp->getGrants() ); + + $this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) ); + + $this->setMwGlobals( array( + 'wgCentralIdLookupProvider' => 'BotPasswordTest FailMock' + ) ); + $this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) ); + + $this->assertSame( '@', BotPassword::getSeparator() ); + $this->setMwGlobals( array( + 'wgUserrightsInterwikiDelimiter' => '#', + ) ); + $this->assertSame( '#', BotPassword::getSeparator() ); + } + + public function testUnsaved() { + $user = User::newFromName( 'UTSysop' ); + $bp = BotPassword::newUnsaved( array( + 'user' => $user, + 'appId' => 'DoesNotExist' + ) ); + $this->assertInstanceOf( 'BotPassword', $bp ); + $this->assertFalse( $bp->isSaved() ); + $this->assertSame( 42, $bp->getUserCentralId() ); + $this->assertSame( 'DoesNotExist', $bp->getAppId() ); + $this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() ); + $this->assertSame( array(), $bp->getGrants() ); + + $bp = BotPassword::newUnsaved( array( + 'username' => 'UTDummy', + 'appId' => 'DoesNotExist2', + 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ), + 'grants' => array( 'test' ), + ) ); + $this->assertInstanceOf( 'BotPassword', $bp ); + $this->assertFalse( $bp->isSaved() ); + $this->assertSame( 43, $bp->getUserCentralId() ); + $this->assertSame( 'DoesNotExist2', $bp->getAppId() ); + $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() ); + $this->assertSame( array( 'test' ), $bp->getGrants() ); + + $user = User::newFromName( 'UTSysop' ); + $bp = BotPassword::newUnsaved( array( + 'centralId' => 45, + 'appId' => 'DoesNotExist' + ) ); + $this->assertInstanceOf( 'BotPassword', $bp ); + $this->assertFalse( $bp->isSaved() ); + $this->assertSame( 45, $bp->getUserCentralId() ); + $this->assertSame( 'DoesNotExist', $bp->getAppId() ); + + $user = User::newFromName( 'UTSysop' ); + $bp = BotPassword::newUnsaved( array( + 'user' => $user, + 'appId' => 'BotPassword' + ) ); + $this->assertInstanceOf( 'BotPassword', $bp ); + $this->assertFalse( $bp->isSaved() ); + + $this->assertNull( BotPassword::newUnsaved( array( + 'user' => $user, + 'appId' => '', + ) ) ); + $this->assertNull( BotPassword::newUnsaved( array( + 'user' => $user, + 'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ), + ) ) ); + $this->assertNull( BotPassword::newUnsaved( array( + 'user' => 'UTSysop', + 'appId' => 'Ok', + ) ) ); + $this->assertNull( BotPassword::newUnsaved( array( + 'username' => 'UTInvalid', + 'appId' => 'Ok', + ) ) ); + $this->assertNull( BotPassword::newUnsaved( array( + 'appId' => 'Ok', + ) ) ); + } + + public function testGetPassword() { + $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); + + $password = $bp->getPassword(); + $this->assertInstanceOf( 'Password', $password ); + $this->assertTrue( $password->equals( 'foobaz' ) ); + + $bp->centralId = 44; + $password = $bp->getPassword(); + $this->assertInstanceOf( 'InvalidPassword', $password ); + + $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'bot_passwords', + array( 'bp_password' => 'garbage' ), + array( 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ), + __METHOD__ + ); + $password = $bp->getPassword(); + $this->assertInstanceOf( 'InvalidPassword', $password ); + } + + public function testInvalidateAllPasswordsForUser() { + $bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); + $bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) ); + + $this->assertNotInstanceOf( 'InvalidPassword', $bp1->getPassword(), 'sanity check' ); + $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword(), 'sanity check' ); + BotPassword::invalidateAllPasswordsForUser( 'UTSysop' ); + $this->assertInstanceOf( 'InvalidPassword', $bp1->getPassword() ); + $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword() ); + + $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); + $this->assertInstanceOf( 'InvalidPassword', $bp->getPassword() ); + } + + public function testRemoveAllPasswordsForUser() { + $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' ); + $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' ); + + BotPassword::removeAllPasswordsForUser( 'UTSysop' ); + + $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) ); + $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) ); + } + + public function testLogin() { + // Test failure when bot passwords aren't enabled + $this->setMwGlobals( 'wgEnableBotPasswords', false ); + $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest ); + $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status ); + $this->setMwGlobals( 'wgEnableBotPasswords', true ); + + // Test failure when BotPasswordSessionProvider isn't configured + $manager = new SessionManager( array( + 'logger' => new Psr\Log\NullLogger, + 'store' => new EmptyBagOStuff, + ) ); + $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); + $this->assertNull( + $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' ), + 'sanity check' + ); + $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest ); + $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status ); + ScopedCallback::consume( $reset ); + + // Now configure BotPasswordSessionProvider for further tests... + $mainConfig = RequestContext::getMain()->getConfig(); + $config = new HashConfig( array( + 'SessionProviders' => $mainConfig->get( 'SessionProviders' ) + array( + 'MediaWiki\\Session\\BotPasswordSessionProvider' => array( + 'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider', + 'args' => array( array( 'priority' => 40 ) ), + ) + ), + ) ); + $manager = new SessionManager( array( + 'config' => new MultiConfig( array( $config, RequestContext::getMain()->getConfig() ) ), + 'logger' => new Psr\Log\NullLogger, + 'store' => new EmptyBagOStuff, + ) ); + $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager ); + + // No "@"-thing in the username + $status = BotPassword::login( 'UTSysop', 'foobaz', new FauxRequest ); + $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status ); + + // No base user + $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest ); + $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status ); + + // No bot password + $status = BotPassword::login( 'UTSysop@DoesNotExist', 'foobaz', new FauxRequest ); + $this->assertEquals( + Status::newFatal( 'botpasswords-not-exist', 'UTSysop', 'DoesNotExist' ), + $status + ); + + // Failed restriction + $request = $this->getMock( 'FauxRequest', array( 'getIP' ) ); + $request->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( '10.0.0.1' ) ); + $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request ); + $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status ); + + // Wrong password + $status = BotPassword::login( 'UTSysop@BotPassword', 'UTSysopPassword', new FauxRequest ); + $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status ); + + // Success! + $request = new FauxRequest; + $this->assertNotInstanceOf( + 'MediaWiki\\Session\\BotPasswordSessionProvider', + $request->getSession()->getProvider(), + 'sanity check' + ); + $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request ); + $this->assertInstanceOf( 'Status', $status ); + $this->assertTrue( $status->isGood() ); + $session = $status->getValue(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertInstanceOf( + 'MediaWiki\\Session\\BotPasswordSessionProvider', $session->getProvider() + ); + $this->assertSame( $session->getId(), $request->getSession()->getId() ); + + ScopedCallback::consume( $reset ); + } + + /** + * @dataProvider provideSave + * @param string|null $password + */ + public function testSave( $password ) { + $passwordFactory = new \PasswordFactory(); + $passwordFactory->init( \RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordFactory->setDefaultType( 'A' ); + + $bp = BotPassword::newUnsaved( array( + 'centralId' => 42, + 'appId' => 'TestSave', + 'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ), + 'grants' => array( 'test' ), + ) ); + $this->assertFalse( $bp->isSaved(), 'sanity check' ); + $this->assertNull( + BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check' + ); + + $pwhash = $password ? $passwordFactory->newFromPlaintext( $password ) : null; + $this->assertFalse( $bp->save( 'update', $pwhash ) ); + $this->assertTrue( $bp->save( 'insert', $pwhash ) ); + $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ); + $this->assertInstanceOf( 'BotPassword', $bp2 ); + $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() ); + $this->assertEquals( $bp->getAppId(), $bp2->getAppId() ); + $this->assertEquals( $bp->getToken(), $bp2->getToken() ); + $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() ); + $this->assertEquals( $bp->getGrants(), $bp2->getGrants() ); + $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword(); + if ( $password === null ) { + $this->assertInstanceOf( 'InvalidPassword', $pw ); + } else { + $this->assertTrue( $pw->equals( $password ) ); + } + + $token = $bp->getToken(); + $this->assertFalse( $bp->save( 'insert' ) ); + $this->assertTrue( $bp->save( 'update' ) ); + $this->assertNotEquals( $token, $bp->getToken() ); + $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ); + $this->assertInstanceOf( 'BotPassword', $bp2 ); + $this->assertEquals( $bp->getToken(), $bp2->getToken() ); + $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword(); + if ( $password === null ) { + $this->assertInstanceOf( 'InvalidPassword', $pw ); + } else { + $this->assertTrue( $pw->equals( $password ) ); + } + + $pwhash = $passwordFactory->newFromPlaintext( 'XXX' ); + $token = $bp->getToken(); + $this->assertTrue( $bp->save( 'update', $pwhash ) ); + $this->assertNotEquals( $token, $bp->getToken() ); + $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword(); + $this->assertTrue( $pw->equals( 'XXX' ) ); + + $this->assertTrue( $bp->delete() ); + $this->assertFalse( $bp->isSaved() ); + $this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ) ); + + $this->assertFalse( $bp->save( 'foobar' ) ); + } + + public static function provideSave() { + return array( + array( null ), + array( 'foobar' ), + ); + } +} diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 45c4b8c5fa..aadc5c9f7c 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -446,89 +446,4 @@ class UserTest extends MediaWikiTestCase { $this->assertGreaterThan( $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" ); } - - public static function setExtendedLoginCookieDataProvider() { - $data = array(); - $now = time(); - - $secondsInDay = 86400; - - // Arbitrary durations, in units of days, to ensure it chooses the - // right one. There is a 5-minute grace period (see testSetExtendedLoginCookie) - // to work around slow tests, since we're not currently mocking time() for PHP. - - $durationOne = $secondsInDay * 5; - $durationTwo = $secondsInDay * 29; - $durationThree = $secondsInDay * 17; - - // If $wgExtendedLoginCookieExpiration is null, then the expiry passed to - // set cookie is time() + $wgCookieExpiration - $data[] = array( - null, - $durationOne, - $now + $durationOne, - ); - - // If $wgExtendedLoginCookieExpiration isn't null, then the expiry passed to - // set cookie is $now + $wgExtendedLoginCookieExpiration - $data[] = array( - $durationTwo, - $durationThree, - $now + $durationTwo, - ); - - return $data; - } - - /** - * @dataProvider setExtendedLoginCookieDataProvider - * @covers User::getRequest - * @covers User::setCookie - * @backupGlobals enabled - */ - public function testSetExtendedLoginCookie( - $extendedLoginCookieExpiration, - $cookieExpiration, - $expectedExpiry - ) { - $this->setMwGlobals( array( - 'wgExtendedLoginCookieExpiration' => $extendedLoginCookieExpiration, - 'wgCookieExpiration' => $cookieExpiration, - ) ); - - $response = $this->getMock( 'WebResponse' ); - $setcookieSpy = $this->any(); - $response->expects( $setcookieSpy ) - ->method( 'setcookie' ); - - $request = new MockWebRequest( $response ); - $user = new UserProxy( User::newFromSession( $request ) ); - $user->setExtendedLoginCookie( 'name', 'value', true ); - - $setcookieInvocations = $setcookieSpy->getInvocations(); - $setcookieInvocation = end( $setcookieInvocations ); - $actualExpiry = $setcookieInvocation->parameters[2]; - - // TODO: ± 600 seconds compensates for - // slow-running tests. However, the dependency on the time - // function should be removed. This requires some way - // to mock/isolate User->setExtendedLoginCookie's call to time() - $this->assertEquals( $expectedExpiry, $actualExpiry, '', 600 ); - } -} - -class UserProxy extends User { - - /** - * @var User - */ - protected $user; - - public function __construct( User $user ) { - $this->user = $user; - } - - public function setExtendedLoginCookie( $name, $value, $secure ) { - $this->user->setExtendedLoginCookie( $name, $value, $secure ); - } } diff --git a/tests/phpunit/includes/utils/BatchRowUpdateTest.php b/tests/phpunit/includes/utils/BatchRowUpdateTest.php index d224af8ed0..61d9a70a15 100644 --- a/tests/phpunit/includes/utils/BatchRowUpdateTest.php +++ b/tests/phpunit/includes/utils/BatchRowUpdateTest.php @@ -221,7 +221,6 @@ class BatchRowUpdateTest extends MediaWikiTestCase { return call_user_func_array( array( $this, 'onConsecutiveCalls' ), $retvals ); } - protected function genSelectResult( $batchSize, $numRows, $rowGenerator ) { $res = array(); for ( $i = 0; $i < $numRows; $i += $batchSize ) { diff --git a/tests/phpunit/includes/utils/MWCryptHKDFTest.php b/tests/phpunit/includes/utils/MWCryptHKDFTest.php index 2c51af3ce9..5dc049831b 100644 --- a/tests/phpunit/includes/utils/MWCryptHKDFTest.php +++ b/tests/phpunit/includes/utils/MWCryptHKDFTest.php @@ -91,6 +91,4 @@ class MWCryptHKDFTest extends MediaWikiTestCase { ); // @codingStandardsIgnoreEnd } - - } diff --git a/tests/phpunit/includes/utils/MWGrantsTest.php b/tests/phpunit/includes/utils/MWGrantsTest.php new file mode 100644 index 0000000000..9d0d962996 --- /dev/null +++ b/tests/phpunit/includes/utils/MWGrantsTest.php @@ -0,0 +1,117 @@ +setMwGlobals( array( + 'wgGrantPermissions' => array( + 'hidden1' => array( 'read' => true, 'autoconfirmed' => false ), + 'hidden2' => array( 'autoconfirmed' => true ), + 'normal' => array( 'edit' => true ), + 'normal2' => array( 'edit' => true, 'create' => true ), + 'admin' => array( 'protect' => true, 'delete' => true ), + ), + 'wgGrantPermissionGroups' => array( + 'hidden1' => 'hidden', + 'hidden2' => 'hidden', + 'normal' => 'normal-group', + 'admin' => 'admin', + ), + ) ); + } + + /** + * @covers MWGrants::getValidGrants + */ + public function testGetValidGrants() { + $this->assertSame( + array( 'hidden1', 'hidden2', 'normal', 'normal2', 'admin' ), + MWGrants::getValidGrants() + ); + } + + /** + * @covers MWGrants::getRightsByGrant + */ + public function testGetRightsByGrant() { + $this->assertSame( + array( + 'hidden1' => array( 'read' ), + 'hidden2' => array( 'autoconfirmed' ), + 'normal' => array( 'edit' ), + 'normal2' => array( 'edit', 'create' ), + 'admin' => array( 'protect', 'delete' ), + ), + MWGrants::getRightsByGrant() + ); + } + + /** + * @dataProvider provideGetGrantRights + * @covers MWGrants::getGrantRights + * @param array|string $grants + * @param array $rights + */ + public function testGetGrantRights( $grants, $rights ) { + $this->assertSame( $rights, MWGrants::getGrantRights( $grants ) ); + } + + public static function provideGetGrantRights() { + return array( + array( 'hidden1', array( 'read' ) ), + array( array( 'hidden1', 'hidden2', 'hidden3' ), array( 'read', 'autoconfirmed' ) ), + array( array( 'normal1', 'normal2' ), array( 'edit', 'create' ) ), + ); + } + + /** + * @dataProvider provideGrantsAreValid + * @covers MWGrants::grantsAreValid + * @param array $grants + * @param bool $valid + */ + public function testGrantsAreValid( $grants, $valid ) { + $this->assertSame( $valid, MWGrants::grantsAreValid( $grants ) ); + } + + public static function provideGrantsAreValid() { + return array( + array( array( 'hidden1', 'hidden2' ), true ), + array( array( 'hidden1', 'hidden3' ), false ), + ); + } + + /** + * @dataProvider provideGetGrantGroups + * @covers MWGrants::getGrantGroups + * @param array|null $grants + * @param array $expect + */ + public function testGetGrantGroups( $grants, $expect ) { + $this->assertSame( $expect, MWGrants::getGrantGroups( $grants ) ); + } + + public static function provideGetGrantGroups() { + return array( + array( null, array( + 'hidden' => array( 'hidden1', 'hidden2' ), + 'normal-group' => array( 'normal' ), + 'other' => array( 'normal2' ), + 'admin' => array( 'admin' ), + ) ), + array( array( 'hidden1', 'normal' ), array( + 'hidden' => array( 'hidden1' ), + 'normal-group' => array( 'normal' ), + ) ), + ); + } + + /** + * @covers MWGrants::getHiddenGrants + */ + public function testGetHiddenGrants() { + $this->assertSame( array( 'hidden1', 'hidden2' ), MWGrants::getHiddenGrants() ); + } + +} diff --git a/tests/phpunit/includes/utils/MWRestrictionsTest.php b/tests/phpunit/includes/utils/MWRestrictionsTest.php new file mode 100644 index 0000000000..66a1130c54 --- /dev/null +++ b/tests/phpunit/includes/utils/MWRestrictionsTest.php @@ -0,0 +1,215 @@ + array( + '10.0.0.0/8', + '172.16.0.0/12', + '2001:db8::/33', + ) + ) ); + } + + /** + * @covers MWRestrictions::newDefault + * @covers MWRestrictions::__construct + */ + public function testNewDefault() { + $ret = MWRestrictions::newDefault(); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( + '{"IPAddresses":["0.0.0.0/0","::/0"]}', + $ret->toJson() + ); + } + + /** + * @covers MWRestrictions::newFromArray + * @covers MWRestrictions::__construct + * @covers MWRestrictions::loadFromArray + * @covers MWRestrictions::toArray + * @dataProvider provideArray + * @param array $data + * @param bool|InvalidArgumentException $expect True if the call succeeds, + * otherwise the exception that should be thrown. + */ + public function testArray( $data, $expect ) { + if ( $expect === true ) { + $ret = MWRestrictions::newFromArray( $data ); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( $data, $ret->toArray() ); + } else { + try { + MWRestrictions::newFromArray( $data ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public static function provideArray() { + return array( + array( array( 'IPAddresses' => array() ), true ), + array( array( 'IPAddresses' => array( '127.0.0.1/32' ) ), true ), + array( + array( 'IPAddresses' => array( '256.0.0.1/32' ) ), + new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' ) + ), + array( + array( 'IPAddresses' => '127.0.0.1/32' ), + new InvalidArgumentException( 'IPAddresses is not an array' ) + ), + array( + array(), + new InvalidArgumentException( 'Array is missing required keys: IPAddresses' ) + ), + array( + array( 'foo' => 'bar', 'bar' => 42 ), + new InvalidArgumentException( 'Array contains invalid keys: foo, bar' ) + ), + ); + } + + /** + * @covers MWRestrictions::newFromJson + * @covers MWRestrictions::__construct + * @covers MWRestrictions::loadFromArray + * @covers MWRestrictions::toJson + * @covers MWRestrictions::__toString + * @dataProvider provideJson + * @param string $json + * @param array|InvalidArgumentException $expect + */ + public function testJson( $json, $expect ) { + if ( is_array( $expect ) ) { + $ret = MWRestrictions::newFromJson( $json ); + $this->assertInstanceOf( 'MWRestrictions', $ret ); + $this->assertSame( $expect, $ret->toArray() ); + + $this->assertSame( $json, $ret->toJson( false ) ); + $this->assertSame( $json, (string)$ret ); + + $this->assertSame( + FormatJson::encode( $expect, true, FormatJson::ALL_OK ), + $ret->toJson( true ) + ); + } else { + try { + MWRestrictions::newFromJson( $json ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + } + } + + public static function provideJson() { + return array( + array( + '{"IPAddresses":[]}', + array( 'IPAddresses' => array() ) + ), + array( + '{"IPAddresses":["127.0.0.1/32"]}', + array( 'IPAddresses' => array( '127.0.0.1/32' ) ) + ), + array( + '{"IPAddresses":["256.0.0.1/32"]}', + new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' ) + ), + array( + '{"IPAddresses":"127.0.0.1/32"}', + new InvalidArgumentException( 'IPAddresses is not an array' ) + ), + array( + '{}', + new InvalidArgumentException( 'Array is missing required keys: IPAddresses' ) + ), + array( + '{"foo":"bar","bar":42}', + new InvalidArgumentException( 'Array contains invalid keys: foo, bar' ) + ), + array( + '{"IPAddresses":[]', + new InvalidArgumentException( 'Invalid restrictions JSON' ) + ), + array( + '"IPAddresses"', + new InvalidArgumentException( 'Invalid restrictions JSON' ) + ), + ); + } + + /** + * @covers MWRestrictions::checkIP + * @dataProvider provideCheckIP + * @param string $ip + * @param bool $pass + */ + public function testCheckIP( $ip, $pass ) { + $this->assertSame( $pass, self::$restrictionsForChecks->checkIP( $ip ) ); + } + + public static function provideCheckIP() { + return array( + array( '10.0.0.1', true ), + array( '172.16.0.0', true ), + array( '192.0.2.1', false ), + array( '2001:db8:1::', true ), + array( '2001:0db8:0000:0000:0000:0000:0000:0000', true ), + array( '2001:0DB8:8000::', false ), + ); + } + + /** + * @covers MWRestrictions::check + * @dataProvider provideCheck + * @param WebRequest $request + * @param Status $expect + */ + public function testCheck( $request, $expect ) { + $this->assertEquals( $expect, self::$restrictionsForChecks->check( $request ) ); + } + + public function provideCheck() { + $ret = array(); + + $mockBuilder = $this->getMockBuilder( 'FauxRequest' ) + ->setMethods( array( 'getIP' ) ); + + foreach ( self::provideCheckIP() as $checkIP ) { + $ok = array(); + $request = $mockBuilder->getMock(); + + $request->expects( $this->any() )->method( 'getIP' ) + ->will( $this->returnValue( $checkIP[0] ) ); + $ok['ip'] = $checkIP[1]; + + /* If we ever add more restrictions, add nested for loops here: + * foreach ( self::provideCheckFoo() as $checkFoo ) { + * $request->expects( $this->any() )->method( 'getFoo' ) + * ->will( $this->returnValue( $checkFoo[0] ); + * $ok['foo'] = $checkFoo[1]; + * + * foreach ( self::provideCheckBar() as $checkBar ) { + * $request->expects( $this->any() )->method( 'getBar' ) + * ->will( $this->returnValue( $checkBar[0] ); + * $ok['bar'] = $checkBar[1]; + * + * // etc. + * } + * } + */ + + $status = Status::newGood(); + $status->setResult( $ok === array_filter( $ok ), $ok ); + $ret[] = array( $request, $status ); + } + + return $ret; + } +} diff --git a/tests/phpunit/maintenance/MaintenanceTest.php b/tests/phpunit/maintenance/MaintenanceTest.php index 5c6a6cde85..245a97af17 100644 --- a/tests/phpunit/maintenance/MaintenanceTest.php +++ b/tests/phpunit/maintenance/MaintenanceTest.php @@ -120,6 +120,16 @@ class MaintenanceFixup extends Maintenance { return call_user_func_array( array( "parent", __FUNCTION__ ), func_get_args() ); } + public function addOption( $name, $description, $required = false, + $withArg = false, $shortName = false, $multiOccurance = false + ) { + return call_user_func_array( array( "parent", __FUNCTION__ ), func_get_args() ); + } + + public function getOption( $name, $default = null ) { + return call_user_func_array( array( "parent", __FUNCTION__ ), func_get_args() ); + } + // --- Requirements for getting instance of abstract class public function execute() { @@ -829,4 +839,37 @@ class MaintenanceTest extends MediaWikiTestCase { $this->m->setConfig( $conf ); $this->assertSame( $conf, $this->m->getConfig() ); } + + function testParseArgs() { + $m2 = new MaintenanceFixup( $this ); + // Create an option with an argument allowed to be specified multiple times + $m2->addOption( 'multi', 'This option does stuff', false, true, false, true ); + $m2->loadWithArgv( array( '--multi', 'this1', '--multi', 'this2' ) ); + + $this->assertEquals( array( 'this1', 'this2' ), $m2->getOption( 'multi' ) ); + $this->assertEquals( array( array( 'multi', 'this1' ), array( 'multi', 'this2' ) ), + $m2->orderedOptions ); + + $m2->simulateShutdown(); + + $m2 = new MaintenanceFixup( $this ); + + $m2->addOption( 'multi', 'This option does stuff', false, false, false, true ); + $m2->loadWithArgv( array( '--multi', '--multi' ) ); + + $this->assertEquals( array( 1, 1 ), $m2->getOption( 'multi' ) ); + $this->assertEquals( array( array( 'multi', 1 ), array( 'multi', 1 ) ), $m2->orderedOptions ); + + $m2->simulateShutdown(); + + $m2 = new MaintenanceFixup( $this ); + // Create an option with an argument allowed to be specified multiple times + $m2->addOption( 'multi', 'This option doesn\'t actually support multiple occurrences' ); + $m2->loadWithArgv( array( '--multi=yo' ) ); + + $this->assertEquals( 'yo', $m2->getOption( 'multi' ) ); + $this->assertEquals( array( array( 'multi', 'yo' ) ), $m2->orderedOptions ); + + $m2->simulateShutdown(); + } } diff --git a/tests/phpunit/maintenance/backupTextPassTest.php b/tests/phpunit/maintenance/backupTextPassTest.php index f5dd98b3fb..893e4f90f3 100644 --- a/tests/phpunit/maintenance/backupTextPassTest.php +++ b/tests/phpunit/maintenance/backupTextPassTest.php @@ -1,10 +1,15 @@ setUpStub(); $nameFull = $this->getNewTempFile(); - $dumper = new TextPassDumper( array( "--stub=file:" - . $nameStub, "--output=file:" . $nameFull ) ); + + $dumper = new TextPassDumper( array( "--stub=file:" . $nameStub, + "--output=file:" . $nameFull ) ); + $dumper->prefetch = $prefetchMock; $dumper->reporting = false; $dumper->setDb( $this->db ); @@ -261,7 +268,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { $this->assertTrue( wfMkdirParents( $nameOutputDir ), "Creating temporary output directory " ); $this->setUpStub( $nameStub, $iterations ); - $dumper = new TextPassDumper( array( "--stub=file:" . $nameStub, + $dumper = new TextPassDumper(); + $dumper->loadWithArgv( array( "--stub=file:" . $nameStub, "--output=" . $checkpointFormat . ":" . $nameOutputDir . "/full", "--maxtime=1" /*This is in minutes. Fixup is below*/, "--buffersize=32768", // The default of 32 iterations fill up 32KB about twice @@ -272,7 +280,7 @@ class TextPassDumperDatabaseTest extends DumpTestCase { // The actual dump and taking time $ts_before = microtime( true ); - $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + $dumper->execute(); $ts_after = microtime( true ); $lastDuration = $ts_after - $ts_before; @@ -634,7 +642,9 @@ class TextPassDumperDatabaselessTest extends MediaWikiLangTestCase { * @dataProvider bufferSizeProvider */ function testBufferSizeSetting( $expected, $size, $msg ) { - $dumper = new TextPassDumperAccessor( array( "--buffersize=" . $size ) ); + $dumper = new TextPassDumperAccessor(); + $dumper->loadWithArgv( array( "--buffersize=" . $size ) ); + $dumper->execute(); $this->assertEquals( $expected, $dumper->getBufferSize(), $msg ); } @@ -674,4 +684,8 @@ class TextPassDumperAccessor extends TextPassDumper { public function getBufferSize() { return $this->bufferSize; } + + function dump( $history, $text = null ) { + return true; + } } diff --git a/tests/phpunit/maintenance/backup_LogTest.php b/tests/phpunit/maintenance/backup_LogTest.php index 7ca45960c5..6629b67dba 100644 --- a/tests/phpunit/maintenance/backup_LogTest.php +++ b/tests/phpunit/maintenance/backup_LogTest.php @@ -2,6 +2,11 @@ /** * Tests for log dumps of BackupDumper * + * Some of these tests use the old constuctor for TextPassDumper + * and the dump() function, while others use the new loadWithArgv( $args ) + * function and execute(). This is to ensure both the old and new methods + * work properly. + * * @group Database * @group Dump * @covers BackupDumper @@ -136,7 +141,8 @@ class BackupDumperLoggerTest extends DumpTestCase { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + + $dumper = new DumpBackup( array( '--output=file:' . $fname ) ); $dumper->startId = $this->logId1; $dumper->endId = $this->logId3 + 1; $dumper->reporting = false; @@ -173,8 +179,10 @@ class BackupDumperLoggerTest extends DumpTestCase { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=gzip:" . $fname, - "--reporting=2" ) ); + + $dumper = new DumpBackup(); + $dumper->loadWithArgv( array( '--logs', '--output=gzip:' . $fname, + '--reporting=2' ) ); $dumper->startId = $this->logId1; $dumper->endId = $this->logId3 + 1; $dumper->setDb( $this->db ); @@ -190,7 +198,7 @@ class BackupDumperLoggerTest extends DumpTestCase { } // Performing the dump - $dumper->dump( WikiExporter::LOGS, WikiExporter::TEXT ); + $dumper->execute(); $this->assertTrue( fclose( $dumper->stderr ), "Closing stderr handle" ); diff --git a/tests/phpunit/maintenance/backup_PageTest.php b/tests/phpunit/maintenance/backup_PageTest.php index 8b6221ba92..5781d1c6d5 100644 --- a/tests/phpunit/maintenance/backup_PageTest.php +++ b/tests/phpunit/maintenance/backup_PageTest.php @@ -6,6 +6,7 @@ * @group Dump * @covers BackupDumper */ + class BackupDumperPageTest extends DumpTestCase { // We'll add several pages, revision and texts. The following variables hold the @@ -98,14 +99,15 @@ class BackupDumperPageTest extends DumpTestCase { function testFullTextPlain() { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + + $dumper = new DumpBackup(); + $dumper->loadWithArgv( array( '--full', '--quiet', '--output', 'file:' . $fname ) ); $dumper->startId = $this->pageId1; $dumper->endId = $this->pageId4 + 1; - $dumper->reporting = false; $dumper->setDb( $this->db ); // Performing the dump - $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + $dumper->execute(); // Checking the dumped data $this->assertDumpStart( $fname ); @@ -153,14 +155,15 @@ class BackupDumperPageTest extends DumpTestCase { function testFullStubPlain() { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + + $dumper = new DumpBackup(); + $dumper->loadWithArgv( array( '--full', '--quiet', '--output', 'file:' . $fname, '--stub' ) ); $dumper->startId = $this->pageId1; $dumper->endId = $this->pageId4 + 1; - $dumper->reporting = false; $dumper->setDb( $this->db ); // Performing the dump - $dumper->dump( WikiExporter::FULL, WikiExporter::STUB ); + $dumper->execute(); // Checking the dumped data $this->assertDumpStart( $fname ); @@ -202,7 +205,8 @@ class BackupDumperPageTest extends DumpTestCase { function testCurrentStubPlain() { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + + $dumper = new DumpBackup( array( '--output', 'file:' . $fname ) ); $dumper->startId = $this->pageId1; $dumper->endId = $this->pageId4 + 1; $dumper->reporting = false; @@ -247,7 +251,8 @@ class BackupDumperPageTest extends DumpTestCase { // Preparing the dump $fname = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=gzip:" . $fname ) ); + + $dumper = new DumpBackup( array( '--output', 'gzip:' . $fname ) ); $dumper->startId = $this->pageId1; $dumper->endId = $this->pageId4 + 1; $dumper->reporting = false; @@ -306,7 +311,7 @@ class BackupDumperPageTest extends DumpTestCase { $fnameMetaCurrent = $this->getNewTempFile(); $fnameArticles = $this->getNewTempFile(); - $dumper = new BackupDumper( array( "--output=gzip:" . $fnameMetaHistory, + $dumper = new DumpBackup( array( "--full", "--stub", "--output=gzip:" . $fnameMetaHistory, "--output=gzip:" . $fnameMetaCurrent, "--filter=latest", "--output=gzip:" . $fnameArticles, "--filter=latest", "--filter=notalk", "--filter=namespace:!NS_USER", diff --git a/tests/phpunit/mocks/session/DummySessionBackend.php b/tests/phpunit/mocks/session/DummySessionBackend.php new file mode 100644 index 0000000000..f96e61c480 --- /dev/null +++ b/tests/phpunit/mocks/session/DummySessionBackend.php @@ -0,0 +1,29 @@ + 1, + 'bar' => 2, + 0 => 'zero', + ); + public $dirty = false; + + public function &getData() { + return $this->data; + } + + public function dirty() { + $this->dirty = true; + } + + public function deregisterSession( $index ) { + } +} diff --git a/tests/phpunit/mocks/session/DummySessionProvider.php b/tests/phpunit/mocks/session/DummySessionProvider.php new file mode 100644 index 0000000000..446819198c --- /dev/null +++ b/tests/phpunit/mocks/session/DummySessionProvider.php @@ -0,0 +1,60 @@ + $this, + 'id' => self::ID, + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + ) ); + } + + public function newSessionInfo( $id = null ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'idIsSafe' => true, + 'provider' => $this, + 'persisted' => false, + 'userInfo' => UserInfo::newAnonymous(), + ) ); + } + + public function persistsSessionId() { + return true; + } + + public function canChangeUser() { + return $this->persistsSessionId(); + } + + public function persistSession( SessionBackend $session, WebRequest $request ) { + } + + public function unpersistSession( WebRequest $request ) { + } + + public function immutableSessionCouldExistForUser( $user ) { + return false; + } + + public function preventImmutableSessionsForUser( $user ) { + } + + public function suggestLoginUsername( WebRequest $request ) { + return $request->getCookie( 'UserName' ); + } + +} diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php index aaa77514f6..0ae0b21385 100755 --- a/tests/phpunit/phpunit.php +++ b/tests/phpunit/phpunit.php @@ -70,9 +70,12 @@ class PHPUnitMaintClass extends Maintenance { parent::finalSetup(); global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType, $wgMainWANCache; + global $wgMainStash; global $wgLanguageConverterCacheType, $wgUseDatabaseMessages; global $wgLocaltimezone, $wgLocalisationCacheConf; global $wgDevelopmentWarnings; + global $wgSessionProviders; + global $wgJobTypeConf; // Inject test autoloader require_once __DIR__ . '/../TestsAutoLoader.php'; @@ -95,6 +98,10 @@ class PHPUnitMaintClass extends Maintenance { $wgLanguageConverterCacheType = 'hash'; // Uses db-replicated in DefaultSettings $wgMainStash = 'hash'; + // Use memory job queue + $wgJobTypeConf = array( + 'default' => array( 'class' => 'JobQueueMemory', 'order' => 'fifo' ), + ); $wgUseDatabaseMessages = false; # Set for future resets @@ -103,6 +110,19 @@ class PHPUnitMaintClass extends Maintenance { $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull'; + // Generic MediaWiki\Session\SessionManager configuration for tests + // We use CookieSessionProvider because things might be expecting + // cookies to show up in a FauxRequest somewhere. + $wgSessionProviders = array( + array( + 'class' => 'MediaWiki\\Session\\CookieSessionProvider', + 'args' => array( array( + 'priority' => 30, + 'callUserSetCookiesHook' => true, + ) ), + ), + ); + // Bug 44192 Do not attempt to send a real e-mail Hooks::clear( 'AlternateUserMailer' ); Hooks::register( @@ -242,7 +262,6 @@ if ( version_compare( PHP_VERSION, '5.4.0', '<' ) ) { } ); } - $ok = false; if ( class_exists( 'PHPUnit_TextUI_Command' ) ) { @@ -251,12 +270,19 @@ if ( class_exists( 'PHPUnit_TextUI_Command' ) ) { } else { foreach ( array( stream_resolve_include_path( 'phpunit.phar' ), + stream_resolve_include_path( 'phpunit-old.phar' ), 'PHPUnit/Runner/Version.php', 'PHPUnit/Autoload.php' ) as $includePath ) { - // @codingStandardsIgnoreStart - @include_once $includePath; - // @codingStandardsIgnoreEnd + + if ( $includePath === false ) { + // stream_resolve_include_path can return false + continue; + } + + \MediaWiki\suppressWarnings(); + include_once $includePath; + \MediaWiki\restoreWarnings(); if ( class_exists( 'PHPUnit_TextUI_Command' ) ) { $ok = true; echo "Using PHPUnit from $includePath\n"; diff --git a/tests/phpunit/structure/ApiDocumentationTest.php b/tests/phpunit/structure/ApiDocumentationTest.php new file mode 100644 index 0000000000..d2f96dc9cb --- /dev/null +++ b/tests/phpunit/structure/ApiDocumentationTest.php @@ -0,0 +1,177 @@ + false, + 'AllowCategorizedRecentChanges' => false, + ), + array( + 'MiserMode' => true, + 'AllowCategorizedRecentChanges' => true, + ), + ); + + /** + * Initialize/fetch the ApiMain instance for testing + * @return ApiMain + */ + private static function getMain() { + if ( !self::$main ) { + self::$main = new ApiMain( RequestContext::getMain() ); + self::$main->getContext()->setLanguage( 'en' ); + } + return self::$main; + } + + /** + * Test a message + * @param Message $msg + * @param string $what Which message is being checked + */ + private function checkMessage( $msg, $what ) { + $msg = ApiBase::makeMessage( $msg, self::getMain()->getContext() ); + $this->assertInstanceOf( 'Message', $msg, "$what message" ); + $this->assertTrue( $msg->exists(), "$what message {$msg->getKey()} exists" ); + } + + /** + * @dataProvider provideDocumentationExists + * @param string $path Module path + * @param array $globals Globals to set + */ + public function testDocumentationExists( $path, array $globals ) { + $main = self::getMain(); + + // Set configuration variables + $main->getContext()->setConfig( new MultiConfig( array( + new HashConfig( $globals ), + RequestContext::getMain()->getConfig(), + ) ) ); + foreach ( $globals as $k => $v ) { + $this->setMWGlobals( "wg$k", $v ); + } + + // Fetch module. + $module = TestingAccessWrapper::newFromObject( $main->getModuleFromPath( $path ) ); + + // Test messages for flags. + foreach ( $module->getHelpFlags() as $flag ) { + $this->checkMessage( "api-help-flag-$flag", "Flag $flag" ); + } + + // Module description messages. + $this->checkMessage( $module->getDescriptionMessage(), 'Module description' ); + + // Parameters. Lots of messages in here. + $params = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP ); + $tags = array(); + foreach ( $params as $name => $settings ) { + if ( !is_array( $settings ) ) { + $settings = array(); + } + + // Basic description message + if ( isset( $settings[ApiBase::PARAM_HELP_MSG] ) ) { + $msg = $settings[ApiBase::PARAM_HELP_MSG]; + } else { + $msg = "apihelp-{$path}-param-{$name}"; + } + $this->checkMessage( $msg, "Parameter $name description" ); + + // If param-per-value is in use, each value's message + if ( isset( $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE] ) ) { + $this->assertInternalType( 'array', $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE], + "Parameter $name PARAM_HELP_MSG_PER_VALUE is array" ); + $this->assertInternalType( 'array', $settings[ApiBase::PARAM_TYPE], + "Parameter $name PARAM_TYPE is array for msg-per-value mode" ); + $valueMsgs = $settings[ApiBase::PARAM_HELP_MSG_PER_VALUE]; + foreach ( $settings[ApiBase::PARAM_TYPE] as $value ) { + if ( isset( $valueMsgs[$value] ) ) { + $msg = $valueMsgs[$value]; + } else { + $msg = "apihelp-{$path}-paramvalue-{$name}-{$value}"; + } + $this->checkMessage( $msg, "Parameter $name value $value" ); + } + } + + // Appended messages (e.g. "disabled in miser mode") + if ( isset( $settings[ApiBase::PARAM_HELP_MSG_APPEND] ) ) { + $this->assertInternalType( 'array', $settings[ApiBase::PARAM_HELP_MSG_APPEND], + "Parameter $name PARAM_HELP_MSG_APPEND is array" ); + foreach ( $settings[ApiBase::PARAM_HELP_MSG_APPEND] as $i => $msg ) { + $this->checkMessage( $msg, "Parameter $name HELP_MSG_APPEND #$i" ); + } + } + + // Info tags (e.g. "only usable in mode 1") are typically shared by + // several parameters, so accumulate them and test them later. + if ( !empty( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) { + foreach ( $settings[ApiBase::PARAM_HELP_MSG_INFO] as $i ) { + $tags[array_shift( $i )] = 1; + } + } + } + + // Info tags (e.g. "only usable in mode 1") accumulated above + foreach ( $tags as $tag => $dummy ) { + $this->checkMessage( "apihelp-{$path}-paraminfo-{$tag}", "HELP_MSG_INFO tag $tag" ); + } + + // Messages for examples. + foreach ( $module->getExamplesMessages() as $qs => $msg ) { + $this->checkMessage( $msg, "Example $qs" ); + } + } + + public static function provideDocumentationExists() { + $main = self::getMain(); + $paths = self::getSubModulePaths( $main->getModuleManager() ); + array_unshift( $paths, $main->getModulePath() ); + + $ret = array(); + foreach ( $paths as $path ) { + foreach ( self::$testGlobals as $globals ) { + $g = array(); + foreach ( $globals as $k => $v ) { + $g[] = "$k=" . var_export( $v, 1 ); + } + $k = "Module $path with " . join( ', ', $g ); + $ret[$k] = array( $path, $globals ); + } + } + return $ret; + } + + /** + * Return paths of all submodules in an ApiModuleManager, recursively + * @param ApiModuleManager $manager + * @return string[] + */ + protected static function getSubModulePaths( ApiModuleManager $manager ) { + $paths = array(); + foreach ( $manager->getNames() as $name ) { + $module = $manager->getModule( $name ); + $paths[] = $module->getModulePath(); + $subManager = $module->getModuleManager(); + if ( $subManager ) { + $paths = array_merge( $paths, self::getSubModulePaths( $subManager ) ); + } + } + return $paths; + } +} diff --git a/tests/phpunit/suites/UploadFromUrlTestSuite.php b/tests/phpunit/suites/UploadFromUrlTestSuite.php index 80893dab41..5e704554f3 100644 --- a/tests/phpunit/suites/UploadFromUrlTestSuite.php +++ b/tests/phpunit/suites/UploadFromUrlTestSuite.php @@ -86,55 +86,9 @@ class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite { RepoGroup::destroySingleton(); FileBackendGroup::destroySingleton(); - $this->teardownUploadDir( $this->uploadDir ); - parent::tearDown(); } - private $uploadDir; - private $keepUploads; - - /** - * Remove the dummy uploads directory - * @param string $dir - */ - private function teardownUploadDir( $dir ) { - if ( $this->keepUploads ) { - return; - } - - // delete the files first, then the dirs. - self::deleteFiles( - array( - "$dir/3/3a/Foobar.jpg", - "$dir/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg", - "$dir/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg", - "$dir/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg", - "$dir/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg", - - "$dir/0/09/Bad.jpg", - ) - ); - - self::deleteDirs( - array( - "$dir/3/3a", - "$dir/3", - "$dir/thumb/6/65", - "$dir/thumb/6", - "$dir/thumb/3/3a/Foobar.jpg", - "$dir/thumb/3/3a", - "$dir/thumb/3", - - "$dir/0/09/", - "$dir/0/", - - "$dir/thumb", - "$dir", - ) - ); - } - /** * Delete the specified files, if they exist. * @@ -170,15 +124,7 @@ class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite { private function setupUploadDir() { global $IP; - if ( $this->keepUploads ) { - $dir = wfTempDir() . '/mwParser-images'; - - if ( is_dir( $dir ) ) { - return $dir; - } - } else { - $dir = $this->getNewTempDirectory(); - } + $dir = $this->getNewTempDirectory(); wfDebug( "Creating upload directory $dir\n" ); diff --git a/tests/phpunit/tests/MediaWikiTestCaseTest.php b/tests/phpunit/tests/MediaWikiTestCaseTest.php index 2846fde000..64def9125a 100644 --- a/tests/phpunit/tests/MediaWikiTestCaseTest.php +++ b/tests/phpunit/tests/MediaWikiTestCaseTest.php @@ -6,58 +6,81 @@ */ class MediaWikiTestCaseTest extends MediaWikiTestCase { - const GLOBAL_KEY_EXISTING = 'MediaWikiTestCaseTestGLOBAL-Existing'; const GLOBAL_KEY_NONEXISTING = 'MediaWikiTestCaseTestGLOBAL-NONExisting'; + private static $startGlobals = array( + 'MediaWikiTestCaseTestGLOBAL-ExistingString' => 'foo', + 'MediaWikiTestCaseTestGLOBAL-ExistingStringEmpty' => '', + 'MediaWikiTestCaseTestGLOBAL-ExistingArray' => array( 1, 'foo' => 'bar' ), + 'MediaWikiTestCaseTestGLOBAL-ExistingArrayEmpty' => array(), + ); + public static function setUpBeforeClass() { parent::setUpBeforeClass(); - $GLOBALS[self::GLOBAL_KEY_EXISTING] = 'foo'; + foreach ( self::$startGlobals as $key => $value ) { + $GLOBALS[$key] = $value; + } } public static function tearDownAfterClass() { parent::tearDownAfterClass(); - unset( $GLOBALS[self::GLOBAL_KEY_EXISTING] ); + foreach ( self::$startGlobals as $key => $value ) { + unset( $GLOBALS[$key] ); + } + } + + public function provideExistingKeysAndNewValues() { + $providedArray = array(); + foreach ( array_keys( self::$startGlobals ) as $key ) { + $providedArray[] = array( $key, 'newValue' ); + $providedArray[] = array( $key, array( 'newValue' ) ); + } + return $providedArray; } /** + * @dataProvider provideExistingKeysAndNewValues + * * @covers MediaWikiTestCase::setMwGlobals * @covers MediaWikiTestCase::tearDown */ - public function testSetGlobalsAreRestoredOnTearDown() { - $this->setMwGlobals( self::GLOBAL_KEY_EXISTING, 'bar' ); + public function testSetGlobalsAreRestoredOnTearDown( $globalKey, $newValue ) { + $this->setMwGlobals( $globalKey, $newValue ); $this->assertEquals( - 'bar', - $GLOBALS[self::GLOBAL_KEY_EXISTING], + $newValue, + $GLOBALS[$globalKey], 'Global failed to correctly set' ); $this->tearDown(); $this->assertEquals( - 'foo', - $GLOBALS[self::GLOBAL_KEY_EXISTING], + self::$startGlobals[$globalKey], + $GLOBALS[$globalKey], 'Global failed to be restored on tearDown' ); } /** + * @dataProvider provideExistingKeysAndNewValues + * * @covers MediaWikiTestCase::stashMwGlobals * @covers MediaWikiTestCase::tearDown */ - public function testStashedGlobalsAreRestoredOnTearDown() { - $this->stashMwGlobals( self::GLOBAL_KEY_EXISTING ); - $GLOBALS[self::GLOBAL_KEY_EXISTING] = 'bar'; + public function testStashedGlobalsAreRestoredOnTearDown( $globalKey, $newValue ) { + $this->stashMwGlobals( $globalKey ); + $GLOBALS[$globalKey] = $newValue; $this->assertEquals( - 'bar', - $GLOBALS[self::GLOBAL_KEY_EXISTING], + $newValue, + $GLOBALS[$globalKey], 'Global failed to correctly set' ); $this->tearDown(); $this->assertEquals( - 'foo', - $GLOBALS[self::GLOBAL_KEY_EXISTING], + self::$startGlobals[$globalKey], + $GLOBALS[$globalKey], 'Global failed to be restored on tearDown' ); } diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 545718a66e..926e986d6b 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -71,6 +71,7 @@ return array( 'tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js', @@ -126,6 +127,7 @@ return array( 'mediawiki.toc', 'mediawiki.Uri', 'mediawiki.user', + 'mediawiki.template.mustache', 'mediawiki.template', 'mediawiki.util', 'mediawiki.special.recentchanges', diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js index 4bcb12e6ae..932ba7d5db 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js @@ -165,6 +165,32 @@ } } ); + QUnit.test( 'makeTitle', 6, function ( assert ) { + var cases, i, title, expected, + NS_MAIN = 0, + NS_TALK = 1, + NS_TEMPLATE = 10; + + cases = [ + [ NS_TEMPLATE, 'Foo', 'Template:Foo' ], + [ NS_TEMPLATE, 'Category:Foo', 'Template:Category:Foo' ], + [ NS_TEMPLATE, 'Template:Foo', 'Template:Template:Foo' ], + [ NS_TALK, 'Help:Foo', null ], + [ NS_TEMPLATE, '<', null ], + [ NS_MAIN, 'Help:Foo', 'Help:Foo' ] + ]; + + for ( i = 0; i < cases.length; i++ ) { + title = mw.Title.makeTitle( cases[ i ][ 0 ], cases[ i ][ 1 ] ); + expected = cases[ i ][ 2 ]; + if ( expected === null ) { + assert.strictEqual( title, expected ); + } else { + assert.strictEqual( title.getPrefixedText(), expected ); + } + } + } ); + QUnit.test( 'Basic parsing', 21, function ( assert ) { var title; title = new mw.Title( 'File:Foo_bar.JPG' ); @@ -332,7 +358,7 @@ } ); - QUnit.test( 'getUrl', 3, function ( assert ) { + QUnit.test( 'getUrl', 4, function ( assert ) { var title; // Config @@ -344,6 +370,9 @@ title = new mw.Title( 'John Doe', 3 ); assert.equal( title.getUrl(), '/wiki/User_talk:John_Doe', 'Escaping in title and namespace for urls' ); + + title = new mw.Title( 'John Cena#And_His_Name_Is', 3 ); + assert.equal( title.getUrl( { meme: true } ), '/wiki/User_talk:John_Cena?meme=true#And_His_Name_Is', 'title with fragment and query parameter' ); } ); QUnit.test( 'newFromImg', 44, function ( assert ) { diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js index 288b527b64..b3c4beeb4e 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js @@ -21,7 +21,7 @@ function () { mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor ); }, - new RegExp( 'The content model \'' + TEST_MODEL + '\' is already registered.' ), + new RegExp( 'Content model "' + TEST_MODEL + '" is already registered' ), 'Throws exception is same model is registered a second time' ); } ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js new file mode 100644 index 0000000000..38ae5e490f --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js @@ -0,0 +1,32 @@ +( function ( mw ) { + + QUnit.module( 'mediawiki.template.mustache', { + setup: function () { + // Stub register some templates + this.sandbox.stub( mw.templates, 'get' ).returns( { + 'test_greeting.mustache': '
            {{foo}}{{>suffix}}
            ', + 'test_greeting_suffix.mustache': ' goodbye' + } ); + } + } ); + + QUnit.test( 'render', 2, function ( assert ) { + var html, htmlPartial, data, partials, + template = mw.template.get( 'stub', 'test_greeting.mustache' ), + partial = mw.template.get( 'stub', 'test_greeting_suffix.mustache' ); + + data = { + foo: 'Hello' + }; + partials = { + suffix: partial + }; + + html = template.render( data ).html(); + htmlPartial = template.render( data, partials ).html(); + + assert.strictEqual( html, 'Hello', 'Render without partial' ); + assert.strictEqual( htmlPartial, 'Hello goodbye', 'Render with partial' ); + } ); + +}( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js index 0b981065ad..5d72179560 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js @@ -137,9 +137,10 @@ } ); QUnit.test( 'getUrl', 12, function ( assert ) { - // Not part of startUp module - mw.config.set( 'wgArticlePath', '/wiki/$1' ); - mw.config.set( 'wgPageName', 'Foobar' ); + mw.config.set( { + wgArticlePath: '/wiki/$1', + wgPageName: 'Foobar' + } ); var href = mw.util.getUrl( 'Sandbox' ); assert.equal( href, '/wiki/Sandbox', 'simple title' ); @@ -181,8 +182,10 @@ QUnit.test( 'wikiScript', 4, function ( assert ) { mw.config.set( { - wgScript: '/w/i.php', // customized wgScript for bug 39103 - wgLoadScript: '/w/l.php', // customized wgLoadScript for bug 39103 + // customized wgScript for T41103 + wgScript: '/w/i.php', + // customized wgLoadScript for T41103 + wgLoadScript: '/w/l.php', wgScriptPath: '/w' } ); diff --git a/tests/qunit/suites/resources/startup.test.js b/tests/qunit/suites/resources/startup.test.js index d3c4748b69..d3f528c414 100644 --- a/tests/qunit/suites/resources/startup.test.js +++ b/tests/qunit/suites/resources/startup.test.js @@ -21,8 +21,7 @@ 'Mozilla/5.0 (Windows NT 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.95 Safari/537.36 OPR/15.0.1147.153', 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36 OPR/16.0.1196.62', 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.125 Safari/537.36 OPR/23.0.1522.75', - // Internet Explorer 8+ - 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)', + // Internet Explorer 9+ 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0)', 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)', 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko', @@ -47,13 +46,14 @@ 'Mozilla/5.0 (Linux; U; Android 2.1; en-us; Nexus One Build/ERD62) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17' ], gradeC: [ - // Internet Explorer < 8 + // Internet Explorer < 9 'Mozilla/2.0 (compatible; MSIE 3.03; Windows 3.1)', 'Mozilla/4.0 (compatible; MSIE 4.01; Windows 95)', 'Mozilla/4.0 (compatible; MSIE 5.0; Windows 98;)', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)', 'Mozilla/5.0 (compatible; MSIE 6.0; Windows NT 5.1)', 'Mozilla/5.0 (compatible; MSIE 7.0; Windows NT 6.0; en-US)', + 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)', // Firefox < 3 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.0.2) Gecko/20060308 Firefox/1.5.0.2', 'Mozilla/5.0 (X11; U; Linux i686; nl; rv:1.8.1.1) Gecko/20070311 Firefox/2.0.0.1', diff --git a/thumb.php b/thumb.php index fed025874b..04b3e42ac8 100644 --- a/thumb.php +++ b/thumb.php @@ -567,7 +567,6 @@ function wfExtractThumbParams( $file, $params ) { return null; } - /** * Output a thumbnail generation error message *