outputTitle(); ?>

@@ -304,9 +295,7 @@ class WebInstallerOutput {
plain(); @@ -325,13 +314,14 @@ class WebInstallerOutput { public function outputShortHeader() { ?> getHeadAttribs() ); ?> + - + <?php $this->outputTitle(); ?> getCssUrl() . "\n"; ?> - getJQuery(); ?> - + getJQuery() . "\n"; ?> + diff --git a/includes/installer/i18n/ar.json b/includes/installer/i18n/ar.json index d04b0f2965..0f78c6295a 100644 --- a/includes/installer/i18n/ar.json +++ b/includes/installer/i18n/ar.json @@ -57,11 +57,11 @@ "config-env-bad": "جرى التحقق من البيئة. لا يمكنك تنصيب ميدياويكي.", "config-env-php": "بي إتش بي $1 مثبت.", "config-env-hhvm": "نصبت HHVM $1.", - "config-unicode-using-intl": "باستخدام [https://pecl.php.net/intl امتداد intl PECL] لتسوية يونيكود.", - "config-unicode-pure-php-warning": "تحذير: لا يتوفر [https://pecl.php.net/intl امتداد intl PECL] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].", + "config-unicode-using-intl": "باستخدام [https://php.net/manual/en/book.intl.php امتداد PHP intl] لتسوية يونيكود.", + "config-unicode-pure-php-warning": "تحذير: لا يتوفر [https://php.net/manual/en/book.intl.php امتداد PHP intl] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].", "config-unicode-update-warning": "تحذير: يستخدم الإصدار المثبت من برنامج تطبيع نظام يونيكود إصدارًا قديما من مكتبة [http://site.icu-project.org/ مشروع ICU];\nتجب عليك [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations الترقية] إذا كنت مهتما باستخدام يونيكود.", "config-no-db": "لا يمكن العثور على مشغل قاعدة بيانات مناسب! تحتاج إلى تثبيت مشغل قاعدة بيانات PHP، \n{{PLURAL:$2|نوع قاعدة البيانات التالي مدعوم|أنواع قاعدة البيانات التالية مدعومة}} البيانات التالية مدعومة: $1.\n\nإذا قمت بتجميع PHP بنفسك، فقم بتكوينها مع تمكين عميل قاعدة البيانات، على سبيل المثال، باستخدام ./configure --with-mysqli.\nإذا قمت بتثبيت PHP من حزمة Debian أو Ubuntu، فستحتاج أيضا إلى تثبيت، على سبيل المثال، حزمة php-mysql.", - "config-outdated-sqlite": "تحذير: لديك SQLite $1, which وهو أقل من الحد الأدنى المطلوب للنسخة $2. SQLite سوف يكون غير متوفر.", + "config-outdated-sqlite": "تحذير: لديك SQLite $2، وهو أقل من الحد الأدنى المطلوب للنسخة $1، SQLite سوف يكون غير متوفر.", "config-no-fts3": "تحذير: يتم تجميع SQLite بدون [//sqlite.org/fts3.html FTS3 module]; ستكون ميزات البحث غير متوفرة في هذه الواجهة الخلفية.", "config-pcre-old": "فادح: مطلوب PCRE $1 أو أحدث،\nترتبط ثنائية PHP الخاصة بك بـPCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE مزيد من المعلومات].", "config-pcre-no-utf8": "فادح: يبدو أن وحدة PCRE في PHP يتم تجميعها بدون دعم PCRE_UTF8، \nيتطلب ميدياويكي دعم UTF-8 ليعمل بشكل صحيح.", diff --git a/includes/installer/i18n/ast.json b/includes/installer/i18n/ast.json index 9432999324..512f032824 100644 --- a/includes/installer/i18n/ast.json +++ b/includes/installer/i18n/ast.json @@ -103,7 +103,7 @@ "config-db-prefix-help": "Si precises compartir una base de datos ente múltiples wikis, o ente MediaWiki y otra aplicación web, puedes optar por amestar un prefixu a tolos nomes de tabla pa evitar conflictos.\nNun utilices espacios.\n\nDe normal déxase esti campu vacío.", "config-mysql-old": "Precísase MySQL $1 o posterior. Tienes $2.", "config-db-port": "Puertu de la base de datos:", - "config-db-schema": "Esquema pa MediaWiki:", + "config-db-schema": "Esquema pa MediaWiki (ensin guiones):", "config-db-schema-help": "Esti esquema de vezu va tar bien.\nCamúdalos solo si sabes que lo precises.", "config-pg-test-error": "Nun puede coneutase cola base de datos $1: $2", "config-sqlite-dir": "Direutoriu de datos SQLite:", @@ -129,7 +129,7 @@ "config-invalid-db-server-oracle": "TNS inválidu pa la base de datos «$1».\nUsa una cadena «TNS Name» o «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Métodos de nomenclatura d'Oracle]).", "config-invalid-db-name": "Nome inválidu de la base de datos «$1».\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9), guiones baxos (_) y guiones (-).", "config-invalid-db-prefix": "Prefixu inválidu pa la base de datos «$1».\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9), guiones baxos (_) y guiones (-).", - "config-connection-error": "$1.\n\nComprueba'l sirvidor, el nome d'usuariu y la contraseña, y tenta nuevamente.", + "config-connection-error": "$1.\n\nComprueba'l sirvidor, el nome d'usuariu y la contraseña, y tenta nuevamente.Si uses \"localhost\" como sirvidor de base de datos, tenta usando \"127.0.0.1\" nel so llugar (o viceversa).", "config-invalid-schema": "Esquema inválidu «$1» pa MediaWiki.\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9) y guiones baxos (_).", "config-db-sys-create-oracle": "L'instalador namái sofita l'usu d'una cuenta SYSDBA pa la creación d'otra cuenta nueva.", "config-db-sys-user-exists-oracle": "La cuenta d'usuariu «$1» yá esiste. ¡SYSDBA sólo puede utilizase pa crear una nueva cuenta!", diff --git a/includes/installer/i18n/be-tarask.json b/includes/installer/i18n/be-tarask.json index 65743dfa17..4146ce474c 100644 --- a/includes/installer/i18n/be-tarask.json +++ b/includes/installer/i18n/be-tarask.json @@ -53,11 +53,11 @@ "config-env-bad": "Асяродзьдзе было праверанае.\nУсталяваньне MediaWiki немагчымае.", "config-env-php": "Усталяваны PHP $1.", "config-env-hhvm": "HHVM $1 усталяваная.", - "config-unicode-using-intl": "Выкарыстоўваецца [https://pecl.php.net/intl intl пашырэньне з PECL] для Unicode-нармалізацыі", - "config-unicode-pure-php-warning": "'''Папярэджаньне''': [https://pecl.php.net/intl Пашырэньне intl з PECL] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў Вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].", + "config-unicode-using-intl": "Выкарыстоўваецца [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] для Unicode-нармалізацыі.", + "config-unicode-pure-php-warning": "Папярэджаньне: [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].", "config-unicode-update-warning": "'''Папярэджаньне''': усталяваная вэрсія бібліятэкі для Unicode-нармалізацыі выкарыстоўвае састарэлую вэрсію бібліятэкі з [http://site.icu-project.org/ праекту ICU].\nРаім [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations абнавіць], калі ваш сайт будзе працаваць з Unicode.", "config-no-db": "Немагчыма знайсьці адпаведны драйвэр базы зьвестак. Вам неабходна ўсталяваць драйвэр базы зьвестак для PHP.\n{{PLURAL:$2|Падтрымліваецца наступны тып базы|Падтрымліваюцца наступныя тыпы базаў}} зьвестак: $1.\n\nКалі вы скампілявалі PHP самастойна, зьмяніце канфігурацыю, каб уключыць кліента базы зьвестак, напрыклад, з дапамогай ./configure --with-mysqli.\nКалі вы ўсталявалі PHP з пакунку Debian або Ubuntu, тады вам трэба дадаткова ўсталяваць, напрыклад, пакунак php-mysql.", - "config-outdated-sqlite": "'''Папярэджаньне''': усталяваны SQLite $1, у той час, калі мінімальная сумяшчальная вэрсія — $2. SQLite ня будзе даступны.", + "config-outdated-sqlite": "Папярэджаньне: усталяваны SQLite $2, у той час, калі мінімальная сумяшчальная вэрсія — $1. SQLite ня будзе даступны.", "config-no-fts3": "'''Папярэджаньне''': SQLite створаны без модуля [//sqlite.org/fts3.html FTS3], для гэтага ўнутранага інтэрфэйсу ня будзе даступная магчымасьць пошуку.", "config-pcre-old": "Крытычная памылка: патрэбны PCRE вэрсіі $1 або пазьнейшай.\nPHP-файл, які выконваецца, зьвязаны з PCRE вэрсіі $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Больш інфармацыі].", "config-pcre-no-utf8": "'''Фатальная памылка''': модуль PCRE для PHP скампіляваны без падтрымкі PCRE_UTF8.\nMediaWiki патрабуе падтрымкі UTF-8 для слушнай працы.", diff --git a/includes/installer/i18n/ca.json b/includes/installer/i18n/ca.json index c9abc94cf3..44cd45e0d3 100644 --- a/includes/installer/i18n/ca.json +++ b/includes/installer/i18n/ca.json @@ -57,18 +57,20 @@ "config-env-bad": "S'ha comprovat l'entorn.\nNo podeu instal·lar el MediaWiki.", "config-env-php": "El PHP $1 està instal·lat.", "config-env-hhvm": "L’HHVM $1 és instal·lat.", - "config-unicode-using-intl": "S'utilitza l'[https://pecl.php.net/intl extensió intl PECL] per a la normalització de l'Unicode.", - "config-unicode-pure-php-warning": "Avís: L'[https://pecl.php.net/intl extensió intl PECL] no és disponible per gestionar la normalització d'Unicode. Es reverteix a una implementació més lenta en PHP pur.\nSi administreu un lloc web amb molt de trànsit, hauríeu de consultar la guia de [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalització d'Unicode].", + "config-unicode-using-intl": "S'utilitza l'[https://php.net/manual/en/book.intl.php extensió intl de PHP] per a la normalització de l'Unicode.", + "config-unicode-pure-php-warning": "Avís: L'[https://php.net/manual/en/book.intl.php extensió intl de PHP] no és disponible per gestionar la normalització d'Unicode. Es reverteix a una implementació més lenta en PHP pur.\nSi administreu un lloc web amb molt de trànsit, hauríeu de consultar la guia de [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalització d'Unicode].", "config-unicode-update-warning": "Avís: La versió instal·lada del contenidor de normalització d'Unicode utilitza una versió antiga de la biblioteca [http://site.icu-project.org/ del projecte ICU].\nHauríeu [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations d'actualitzar-la] si us importa poder utilitzar Unicode.", "config-no-db": "No s'ha pogut trobar un controlador adequat per a la base de dades. Instal·leu-ne un per al PHP.\nHi ha suport per {{PLURAL:$2|al tipus de base de dades següent|als tipus de base de dades següents}}: $1\n\nSi heu compilat el PHP manualment, torneu a configurar-lo amb un client de base de dades habilitat, per exemple fent servir ./configure --with-mysqli.\nSi heu instal·lat el PHP d'un paquet de Debian o Ubuntu, també cal que instal·leu, per exemple, el paquet php-mysql.", - "config-outdated-sqlite": "Avís: teniu el SQLite $1, que és menor que la versió mínima necessària $2. SQLite no estarà disponible.", + "config-outdated-sqlite": "Avís: teniu el SQLite $2, que és menor que la versió mínima necessària $1. SQLite no estarà disponible.", "config-no-fts3": "Avís: SQLite està compilat sense el [//sqlite.org/fts3.html mòdul FTS3], per tant les funcionalitats de cerca no estaran disponibles en aquesta instal·lació.", "config-pcre-old": "Error fatal: Cal el PCRE $1 o superior.\nEl binari PHP que utilitzeu està enllaçat amb el PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Més informació].", + "config-pcre-no-utf8": "Fatal: El mòdul PCRE de PHP sembla que no va compilar-se per funcionar amb PCRE_UTF8.\nMediaWiki necessita que UTF-8 funcioni correctament.", "config-memory-raised": "El memory_limit del PHP és $1 i s'ha aixecat a $2.", "config-memory-bad": "Avís: El memory_limit del PHP és $1.\nAixò és probablement massa baix.\nLa instal·lació pot fallar!", "config-apc": "L'[https://www.php.net/apc APC] està instal·lat", "config-apcu": "L'[https://www.php.net/apcu APCu] està instal·lat", "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] està instal·lat", + "config-no-cache-apcu": "Avís: no s'ha pogut trobar [https://www.php.net/apcu APCu] o [https://www.iis.net/downloads/microsoft/wincache-extension WinCache].\nNo s'habilitarà la memòria cau d'objectes.", "config-diff3-bad": "No s'ha trobat el GNU diff3. Podeu ignorar-ho per ara, però us podeu trobar amb conflictes d'edició més habitualment.", "config-git": "S'ha trobat el programari de control de versions Git: $1.", "config-git-bad": "No s'ha trobat el programari de control de versions Git. Podeu ignorar-ho per ara, però la pàgina Especial:Versió no mostrarà els resums de publicacions.", @@ -184,6 +186,7 @@ "config-admin-error-password": "S'ha produït un error intern en definir una contrasenya per a l'administrador «$1»:
$2
", "config-admin-error-bademail": "Heu introduït una adreça electrònica no vàlida.", "config-subscribe": "Subscriu a la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce llista de correu d'anunci de noves versions].", + "config-subscribe-noemail": "Us heu provat de subscriure a la llista de correu d'anuncis de noves versions sense proporcionar-hi una adreça electrònica.\nProporcioneu-ne una si voleu subscriure-us a la llista de correu electrònic.", "config-pingback": "Comparteix dades d'aquesta instal·lació amb els desenvolupadors de MediaWiki.", "config-almost-done": "Gairebé ja heu acabat!\nPodeu ometre el que queda de la configuració i procedir amb la instal·lació del wiki.", "config-optional-continue": "Fes-me més preguntes.", @@ -214,12 +217,14 @@ "config-email-auth": "Habilita l'autenticació per correu electrònic", "config-email-auth-help": "Si s'habilita l'opció, els usuaris hauran de confirmar llur adreça electrònica utilitzant un enllaç que els enviarem quan la defineixin o la canviïn.\nNomés les adreces electròniques autenticades poden rebre correus d'altres usuaris o canviar les notificacions de correu.\nDefinir aquesta opció és recomanat per a wikis públics per tal d'evitar els possibles abusos de l'ús del correu.", "config-email-sender": "Adreça electrònica de retorn:", + "config-email-sender-help": "Introduïu una adreça electrònica per utilitzar-la com a adreça de retorn dels missatges electrònics de sortida.\nAquí és on s'enviaran els missatges que no arribin a lloc.\nMolts servidors de correu electrònic necessiten com a mínim que la part del nom de domini sigui vàlida.", "config-upload-settings": "Imatges i càrregues de fitxers", "config-upload-enable": "Habilita la càrrega de fitxers", "config-upload-help": "Les càrregues de fitxers potencialment exposen el vostre servidor a riscos de seguretat.\nPer a més informació, llegiu la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security secció de seguretat] del manual.\n\nPer habilitar les càrregues de fitxer, canvieu el mode del subdirectori images del directori arrel de MediaWiki per tal que el servidor web pugui escriure-hi.\nA continuació, habiliteu-ne l'opció.", "config-upload-deleted": "Directori pels arxius suprimits:", "config-upload-deleted-help": "Trieu un directori per a arxivar els fitxers suprimits.\nIdealment no hauria de ser accessible des del web.", "config-logo": "URL del logo:", + "config-logo-help": "L'aparença per defecte de MediaWiki inclou un espai per a un logotip de 135x160 píxels sobre el menú de la barra lateral.\nCarregueu una imatge de la mida apropiada i introduïu un URL aquí.\n\nPodeu utilitzar $wgStylePath o $wgScriptPath si el vostre logotip es relatiu a aquests camins.\n\nSi no voleu cap logotip, deixeu el quadre en blanc.", "config-instantcommons": "Habilita Instant Commons", "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] és una característica que permet que els wikis utilitzin imatges, sons i altres fitxers multimèdies que es troben al lloc web de [https://commons.wikimedia.org/ Wikimedia Commons].\nPer a això, cal que el MediaWiki tingui accés a Internet.\n\nPer a més informació d'aquesta característica, amb instuccions de com definir altres wikis apart de Wikimedia Commons, consulteu [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos el manual].", "config-cc-error": "El selector de llicència Creative Commons no ha donat cap resultat.\nIntroduïu la llicència manualment.", @@ -240,11 +245,11 @@ "config-extensions": "Extensions", "config-extensions-help": "Les extensions que es llisten a dalt s'han detecta en el directori ./extensions.\n\nPoden necessitar configuració addicional, però ja podeu habilitar-les.", "config-skins": "Aparences", - "config-skins-help": "S'han detectat els temes llistats a dalt en el directori ./skins. Heu d'habilitar-ne com a mínim un i trieu-ne el predeterminat.", - "config-skins-use-as-default": "Utilitza aquest tema per defecte", - "config-skins-missing": "No s'ha trobat cap tema; MediaWiki utilitzarà el tema per defecte fins que hi instal·leu alguns adequats.", + "config-skins-help": "S'han detectat les aparences llistades a dalt en el directori ./skins. Heu d'habilitar-ne com a mínim un i trieu-ne el predeterminat.", + "config-skins-use-as-default": "Utilitza aquesta aparença per defecte", + "config-skins-missing": "No s'ha trobat cap aparença; MediaWiki utilitzarà l'aparença per defecte fins que hi instal·leu algunes adequades.", "config-skins-must-enable-some": "Heu de triar com a mínim un tema per habilitar.", - "config-skins-must-enable-default": "Cal habilitar el tema triat per defecte.", + "config-skins-must-enable-default": "Cal habilitar l'aparença triada per defecte.", "config-install-alreadydone": "Avís: Sembla que ja havíeu instal·lat MediaWiki i esteu provant d'instal·lar-lo de nou.\nProcediu a la pàgina següent.", "config-install-begin": "En fer clic a «{{int:config-continue}}» s’iniciarà la instal·lació del MediaWiki. Si encara voleu fer canvis, feu clic a «{{int:config-back}}».", "config-install-step-done": "fet", diff --git a/includes/installer/i18n/cs.json b/includes/installer/i18n/cs.json index 944ecbe572..cf341e46f0 100644 --- a/includes/installer/i18n/cs.json +++ b/includes/installer/i18n/cs.json @@ -12,7 +12,8 @@ "Seb35", "Ilimanaq29", "Dvorapa", - "Patriccck" + "Patriccck", + "Tchoř" ] }, "config-desc": "Instalační program pro MediaWiki", @@ -58,11 +59,11 @@ "config-env-bad": "Prostředí bylo zkontrolováno.\nMediaWiki nelze nainstalovat.", "config-env-php": "Je nainstalováno PHP $1.", "config-env-hhvm": "Je nainstalováno HHVM $1.", - "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://pecl.php.net/intl PECL rozšíření intl].", - "config-unicode-pure-php-warning": "Upozornění: Není dostupné [https://pecl.php.net/intl PECL rozšíření intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst něco o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].", + "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://php.net/manual/en/book.intl.php rozšíření PHP intl].", + "config-unicode-pure-php-warning": "Upozornění: Není dostupné [https://php.net/manual/en/book.intl.php rozšíření PHP intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].", "config-unicode-update-warning": "Upozornění: Nainstalovaná verze vrstvy pro normalizaci Unicode používá starší verzi knihovny [http://site.icu-project.org/ projektu ICU].\nPokud vám aspoň trochu záleží na používání Unicode, měli byste [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ji aktualizovat].", "config-no-db": "Nepodařilo se nalézt vhodný databázový ovladač! Musíte nainstalovat databázový ovladač pro PHP.\n{{PLURAL:$2|Je podporován následující typ databáze|Jsou podporovány následující typy databází}}: $1.\n\nPokud jste si PHP přeložili sami, překonfigurujte ho se zapnutým databázovým klientem, například pomocí ./configure --with-mysqli.\nPokud jste PHP nainstalovali z balíčku Debian či Ubuntu, potřebujete nainstalovat také modul php-mysql.", - "config-outdated-sqlite": "Upozornění: Máte SQLite $1, které je starší než minimálně vyžadovaná verze $2. SQLite nebude dostupné.", + "config-outdated-sqlite": "Upozornění: Máte SQLite $2, které je starší než minimálně vyžadovaná verze $1. SQLite nebude dostupné.", "config-no-fts3": "Upozornění: SQLite bylo přeloženo bez [//sqlite.org/fts3.html modulu FTS3], funkce pro vyhledávání zde nebudou dostupné.", "config-pcre-old": "Kritická chyba: Je vyžadováno PCRE verze $1 nebo novější.\nVaše binárka PHP obsahuje PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Více informací.]", "config-pcre-no-utf8": "Kritická chyba: PHP modul PCRE byl zřejmě přeložen bez podpory PCRE_UTF8.\nMediaWiki vyžaduje ke správné funkci podporu UTF-8.", @@ -138,7 +139,7 @@ "config-missing-db-name": "Musíte zadat hodnotu pro „{{int:config-db-name}}“.", "config-missing-db-host": "Musíte zadat hodnotu pro „{{int:config-db-host}}“.", "config-missing-db-server-oracle": "Musíte zadat hodnotu pro „{{int:config-db-host-oracle}}“.", - "config-invalid-db-server-oracle": "Chybné databázové TNS „$1“.\nPoužívejte buď „TNS Name“ nebo „Easy Connect“ (viz [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).", + "config-invalid-db-server-oracle": "Chybné databázové TNS „$1“.\nPoužívejte buď „TNS Name“ nebo „Easy Connect“ (vizte [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).", "config-invalid-db-name": "Chybné jméno databáze „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).", "config-invalid-db-prefix": "Chybný databázový prefix „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).", "config-connection-error": "$1.\n\nZkontrolujte server, uživatelské jméno a heslo a zkuste to znovu. Pokud jako adresu databázového serveru používáte „localhost“, zkuste použít „127.0.0.1“ (a naopak).", @@ -217,7 +218,7 @@ "config-profile-no-anon": "Vyžadována registrace uživatelů", "config-profile-fishbowl": "Editace jen pro vybrané", "config-profile-private": "Soukromá wiki", - "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; viz [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].", + "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; vizte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].", "config-license": "Autorská práva a licence:", "config-license-none": "Bez patičky s licencí", "config-license-cc-by-sa": "Creative Commons Uveďte autora-Zachovejte licenci", diff --git a/includes/installer/i18n/de.json b/includes/installer/i18n/de.json index 5fcc4c92a6..6bf992147e 100644 --- a/includes/installer/i18n/de.json +++ b/includes/installer/i18n/de.json @@ -224,10 +224,10 @@ "config-profile-help": "Wikis sind am nützlichsten, wenn so viele Menschen als möglich Bearbeitungen vornehmen können.\nMit MediaWiki ist es einfach die letzten Änderungen nachzuvollziehen und unbrauchbare Bearbeitungen, beispielsweise von unbedarften oder böswilligen Benutzern, rückgängig zu machen.\n\nAllerdings finden etliche Menschen Wikis auch mit anderen Bearbeitungskonzepten sinnvoll. Manchmal ist es zudem nicht einfach alle Beteiligten von den Vorteilen des „Wiki-Prinzips” zu überzeugen. Darum ist diese Auswahl möglich.\n\nDas Modell „'''{{int:config-profile-wiki}}'''“ ermöglicht es jedermann, sogar ohne über ein Benutzerkonto zu verfügen, Bearbeitungen vorzunehmen.\nEin Wiki bei dem die '''{{int:config-profile-no-anon}}''' ist, fordert von den Benutzern eine höhere Verantwortung für ihre Bearbeitungen ein, könnte allerdings Personen abschrecken, die nur gelegentlich Bearbeitungen vornehmen wollen. Ein Wiki für '''{{int:config-profile-fishbowl}}''' gestattet es nur bestimmten Benutzern, Bearbeitungen vorzunehmen. Allerdings kann dabei die Allgemeinheit die Seiten immer noch betrachten und Änderungen nachvollziehen. Ein '''{{int:config-profile-private}}''' gestattet es nur ausgewählten Benutzern, Seiten zu betrachten sowie zu bearbeiten.\n\nKomplexere Konzepte zur Zugriffssteuerung können erst nach abgeschlossenem Installationsvorgang eingerichtet werden. Hierzu gibt es weitere Informationen auf der Website mit der [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights entsprechenden Anleitung].", "config-license": "Lizenz:", "config-license-none": "Keine Lizenzangabe in der Fußzeile", - "config-license-cc-by-sa": "''Creative Commons'' „Namensnennung – Weitergabe unter gleichen Bedingungen“", - "config-license-cc-by": "''Creative Commons'' „Namensnennung“", - "config-license-cc-by-nc-sa": "''Creative Commons'' „Namensnennung – nicht kommerziell – Weitergabe unter gleichen Bedingungen“", - "config-license-cc-0": "''Creative Commons'' „Zero“ (Gemeinfreiheit)", + "config-license-cc-by-sa": "Creative Commons „Namensnennung – Weitergabe unter gleichen Bedingungen“", + "config-license-cc-by": "Creative Commons „Namensnennung“", + "config-license-cc-by-nc-sa": "Creative Commons „Namensnennung – nicht kommerziell – Weitergabe unter gleichen Bedingungen“", + "config-license-cc-0": "Creative Commons „Zero“ (Gemeinfreiheit)", "config-license-gfdl": "GNU-Lizenz für freie Dokumentation 1.3 oder höher", "config-license-pd": "Gemeinfreiheit", "config-license-cc-choose": "Eine andere Creative-Commons-Lizenz auswählen", diff --git a/includes/installer/i18n/diq.json b/includes/installer/i18n/diq.json index 4a98d5d7b6..21c72494ac 100644 --- a/includes/installer/i18n/diq.json +++ b/includes/installer/i18n/diq.json @@ -71,7 +71,7 @@ "config-admin-name": "Nameyê şımayê karberi:", "config-admin-password": "Parola:", "config-admin-password-confirm": "Fına parola:", - "config-admin-email": "Adresa e-postey:", + "config-admin-email": "Adresa e-posteyi:", "config-profile-wiki": "Wiki Ak", "config-profile-private": "Bexse wiki", "config-license": "Heqa telifi û lisans:", diff --git a/includes/installer/i18n/en.json b/includes/installer/i18n/en.json index 485238089b..5b9742b0ca 100644 --- a/includes/installer/i18n/en.json +++ b/includes/installer/i18n/en.json @@ -45,11 +45,11 @@ "config-env-bad": "The environment has been checked.\nYou cannot install MediaWiki.", "config-env-php": "PHP $1 is installed.", "config-env-hhvm": "HHVM $1 is installed.", - "config-unicode-using-intl": "Using the [https://pecl.php.net/intl intl PECL extension] for Unicode normalization.", - "config-unicode-pure-php-warning": "Warning: The [https://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", + "config-unicode-using-intl": "Using the [https://php.net/manual/en/book.intl.php PHP intl extension] for Unicode normalization.", + "config-unicode-pure-php-warning": "Warning: The [https://php.net/manual/en/book.intl.php PHP intl extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", "config-unicode-update-warning": "Warning: The installed version of the Unicode normalization wrapper uses an older version of [http://site.icu-project.org/ the ICU project's] library.\nYou should [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if you are at all concerned about using Unicode.", "config-no-db": "Could not find a suitable database driver! You need to install a database driver for PHP.\nThe following database {{PLURAL:$2|type is|types are}} supported: $1.\n\nIf you compiled PHP yourself, reconfigure it with a database client enabled, for example, using ./configure --with-mysqli.\nIf you installed PHP from a Debian or Ubuntu package, then you also need to install, for example, the php-mysql package.", - "config-outdated-sqlite": "Warning: you have SQLite $1, which is lower than minimum required version $2. SQLite will be unavailable.", + "config-outdated-sqlite": "Warning: you have SQLite $2, which is lower than minimum required version $1. SQLite will be unavailable.", "config-no-fts3": "Warning: SQLite is compiled without the [//sqlite.org/fts3.html FTS3 module], search features will be unavailable on this backend.", "config-pcre-old": "Fatal: PCRE $1 or later is required.\nYour PHP binary is linked with PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE More information].", "config-pcre-no-utf8": "Fatal: PHP's PCRE module seems to be compiled without PCRE_UTF8 support.\nMediaWiki requires UTF-8 support to function correctly.", diff --git a/includes/installer/i18n/eo.json b/includes/installer/i18n/eo.json index 364209c6c3..6d52241fe9 100644 --- a/includes/installer/i18n/eo.json +++ b/includes/installer/i18n/eo.json @@ -8,12 +8,14 @@ "Ochilov", "Tlustulimu", "Robin van der Vliet", - "YvesNevelsteen" + "YvesNevelsteen", + "Mirin" ] }, "config-desc": "La instalilo de MediaWiki", "config-title": "Instalado de MediaWiki $1", "config-information": "Informoj", + "config-localsettings-key": "Ĝisdatiga ŝlosilo:", "config-localsettings-badkey": "La ĝisdatiga ŝlosilo kiun vi enigis, estas malĝusta.", "config-your-language": "Via lingvo:", "config-your-language-help": "Elektu la lingvon kiun vi volas uzi dum la instalada procezo.", @@ -41,13 +43,25 @@ "config-env-bad": "La medio estas kontrolita.\nNe eblas instali MediaWiki.", "config-env-php": "PHP $1 estas instalita.", "config-env-hhvm": "HHVM $1 estas instalita.", + "config-memory-raised": "La parametro memory_limit de PHP estis $1, ŝanĝita al $2.", "config-apc": "[https://www.php.net/apc APC] estas instalita", "config-apcu": "[https://www.php.net/apcu APCu] estas instalita", "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] estas instalita", "config-diff3-bad": "GNU diff3 ne estis trovita.", + "config-using-server": "Uzante servilonomon \"$1\".", + "config-using-uri": "Uzante servilan retadreson \"$1$2\".", "config-db-type": "Tipo de datumbazo:", + "config-db-host": "Datenbanka gastigilo:", "config-db-wiki-settings": "Identigu ĉi tiun vikion", "config-db-name": "Nomo de datumbazo:", + "config-db-name-oracle": "Datenbanka skemo:", + "config-db-username": "Datenbanka uzantnomo:", + "config-db-password": "Datenbanka pasvorto:", + "config-db-port": "Datenbanka pordo:", + "config-db-schema": "Skemo por MediaVikio (sen streketo):", + "config-sqlite-dir": "Datena dosierujo por SQLite:", + "config-oracle-def-ts": "Implicita tabelspaco:", + "config-oracle-temp-ts": "Portempa tabelspaco:", "config-type-mysql": "MariaDB, MySQL, aŭ kongrua", "config-type-mssql": "Microsoft SQL Server", "config-header-mysql": "MairaDB/MySQL-agordoj", @@ -55,15 +69,67 @@ "config-header-sqlite": "SQLite-agordoj", "config-header-oracle": "Oracle-agordoj", "config-header-mssql": "Microsoft SQL Server-agordoj", + "config-invalid-db-type": "Nevalida speco de datenbanko.", + "config-sqlite-readonly": "La dosiero $1 ne estas surskribebla.", + "config-sqlite-cant-create-db": "Ne povis krei datenbankan dosieron $1.", + "config-regenerate": "Refari dosieron LocalSettings.php →", + "config-mysql-engine": "Konservada modulo:", + "config-mysql-innodb": "InnoDB (rekomendata)", + "config-mysql-myisam": "MyISAM", + "config-mssql-auth": "Speco de aŭtentokontrolo:", + "config-mssql-sqlauth": "Aŭtentokontrolo de Microsoft SQL-Servilo", + "config-mssql-windowsauth": "Aŭtentokontrolo de Windows", "config-site-name": "Nomo de vikio:", + "config-site-name-blank": "Enigu nomon de retejo.", + "config-project-namespace": "Projekta nomspaco:", "config-ns-generic": "Projekto", + "config-ns-site-name": "Same kiel la nomo de la vikio: $1", + "config-ns-other": "Alio (specifi)", + "config-ns-other-default": "MiaVikio", + "config-admin-box": "Konto de administranto", "config-admin-name": "Via uzantonomo:", "config-admin-password": "Pasvorto:", "config-admin-password-confirm": "Retajpu pasvorton:", "config-admin-name-blank": "Enigu salutnomon de administranto.", + "config-admin-password-blank": "Enigu pasvorton por la administra konto.", + "config-admin-password-mismatch": "La du pasvortojn enigitaj de vi ne estas egalaj.", "config-admin-email": "Retpoŝtadreso:", + "config-admin-error-bademail": "Vi enigis nevalidan retpoŝtan adreson.", + "config-optional-continue": "Demandu min pli da demandoj.", + "config-optional-skip": "Mi jam enuas; simple instalu la vikion.", + "config-profile": "Profilo de uzantorajtoj:", + "config-profile-wiki": "Malfermita vikio", "config-profile-private": "Privata vikio", "config-license": "Aŭtorrajto kaj permesilo:", + "config-license-cc-by-sa": "Krea Komunaĵo Atribuite-Samkondiĉe", + "config-license-cc-by": "Krea Komunaĵo Atribuite", + "config-license-cc-0": "Krea Komunaĵo Nul (Publika Havaĵo)", + "config-license-pd": "Publika Havaĵo", + "config-email-settings": "Agordo pri retpoŝto", + "config-upload-settings": "Alŝutado de bildoj kaj dosieroj", + "config-upload-enable": "Ebligi alŝutadon de dosieroj", + "config-upload-deleted": "Dosierujo por forigitaj dosieroj:", + "config-logo": "Retadreso de emblemo:", + "config-cc-again": "Reelekti...", + "config-extensions": "Kromprogramoj", + "config-skins": "Etosoj", + "config-skins-use-as-default": "Uzi ĉi tiun etoson kiel implicitan", + "config-install-step-done": "farite", + "config-install-step-failed": "malsukcesis", + "config-install-extensions": "Inkluzive de kromprogramoj", + "config-install-database": "Agordante datenbankon", + "config-install-schema": "Kreante skemon", + "config-install-user": "Kreante datenbankan uzanton", + "config-install-user-create-failed": "Kreado de uzanto \"$1\" malsukcesis: $2", + "config-install-tables": "Kreante tabelojn", + "config-install-interwiki-list": "Ne povis legi dosieron interwiki.list.", + "config-install-stats": "Pretigante statistikon", + "config-install-keys": "Farante sekretajn ŝlosilojn", + "config-download-localsettings": "Elŝuti la dosieron LocalSettings.php", + "config-help": "helpo", + "config-skins-screenshots": "$1 (ekrankopioj: $2)", + "config-extensions-requires": "$1 (postulas $2)", + "config-screenshot": "ekrankopio", "mainpagetext": "'''MediaWiki estis sukcese instalita.'''", "mainpagedocfooter": "Konsultu la [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Gvidilon por uzantoj de MediaWiki] por informoj pri uzado de vikia programaro.\n\n==Kiel komenci==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listo de konfiguraĵoj] (angle)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki Oftaj Demandoj] (angle)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Anonco-dissendolisto pri MediaWiki] (angle)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Preklad MediaWiki do tvojho jazyka]" } diff --git a/includes/installer/i18n/es.json b/includes/installer/i18n/es.json index 6b8ceb2321..6e1d5a2d58 100644 --- a/includes/installer/i18n/es.json +++ b/includes/installer/i18n/es.json @@ -38,7 +38,9 @@ "MarcoAurelio", "Adjen", "Dschultz", - "Carlosmg.dg" + "Carlosmg.dg", + "Harvest", + "Anarhistička Maca" ] }, "config-desc": "El instalador de MediaWiki", @@ -84,11 +86,11 @@ "config-env-bad": "El entorno ha sido comprobado.\nNo puedes instalar MediaWiki.", "config-env-php": "PHP $1 está instalado.", "config-env-hhvm": "HHVM $1 está instalado.", - "config-unicode-using-intl": "Se utiliza la [https://pecl.php.net/intl extensión «intl» de PECL] para la normalización Unicode.", - "config-unicode-pure-php-warning": "Advertencia: la [https://pecl.php.net/intl extensión intl] no está disponible para efectuar la normalización Unicode. Se utilizará la implementación más lenta en PHP puro.\nSi tu web tiene mucho tráfico, te recomendamos leer acerca de la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].", + "config-unicode-using-intl": "Se utiliza la [https://php.net/manual/en/book.intl.php PHP extensión «intl» de PECL] para la normalización Unicode.", + "config-unicode-pure-php-warning": "Advertencia: la [https://php.net/manual/en/book.intl.php PHP extensión intl] no está disponible para efectuar la normalización Unicode. Se utilizará la implementación más lenta en PHP puro.\nSi tu web tiene mucho tráfico, te recomendamos leer acerca [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].", "config-unicode-update-warning": "Atención: la versión instalada del contenedor de normalización de Unicode utiliza una versión anticuada de la biblioteca del [http://site.icu-project.org/ proyecto ICU].\nDeberías [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations modernizarla] si te interesa utilizar Unicode.", "config-no-db": "No se encontró un controlador adecuado para la base de datos. Necesitas instalar un controlador de base de datos para PHP.\n{{PLURAL:$2|Se admite el siguiente gestor de bases de datos|Se admiten los siguientes gestores de bases de datos}}: $1.\n\nSi compilaste PHP por tu cuenta, debes reconfigurarlo activando un cliente de base de datos, por ejemplo, mediante ./configure --with-mysqli.\nSi instalaste PHP desde un paquete de Debian o Ubuntu, también debes instalar, por ejemplo, el paquete php-mysql.", - "config-outdated-sqlite": "Advertencia: tienes SQLite $1, que es inferior a la mínima versión requerida: $2. SQLite no estará disponible.", + "config-outdated-sqlite": "Advertencia: tienes SQLite $2, que es inferior a la mínima versión requerida: $1. SQLite no estará disponible.", "config-no-fts3": "Advertencia: SQLite está compilado sin el [//sqlite.org/fts3.html módulo FTS3]. Las funcionalidades de búsqueda no estarán disponibles en esta instalación.", "config-pcre-old": "'''Fatal:''' Se requiere PCRE $1 o posterior.\nSu PHP binario está enlazado con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Más información].", "config-pcre-no-utf8": "'''Error fatal ''': Parece que el módulo PCRE de PHP fue compilado sin el soporte PCRE_UTF8.\nMediaWiki requiere compatibilidad con UTF-8 para funcionar correctamente.", diff --git a/includes/installer/i18n/fa.json b/includes/installer/i18n/fa.json index b17b0936c5..daa4de984b 100644 --- a/includes/installer/i18n/fa.json +++ b/includes/installer/i18n/fa.json @@ -304,7 +304,7 @@ "config-install-stats": "شروع آمار", "config-install-keys": "تولید کلیدهای مخفی", "config-install-updates": "جلوگیری از به روز رسانی‌های غیر ضروری در حال اجرا", - "config-install-updates-failed": "خطا: قراردادن کلیدهای به روز رسانی به داخل جداول با خطای روبرو مواجه شد: $1", + "config-install-updates-failed": "خطا: قرار دادن کلیدهای روزآمدسازی در جدول‌ها با شکست و این خطا مواجه شد: $1", "config-install-sysop": "ایجاد حساب کاربری مدیر", "config-install-subscribe-fail": "قادر تصدیق اعلام مدیاویکی نیست:$1", "config-install-subscribe-notpossible": "سی‌یوآر‌ال نصب نشده‌است و allow_url_fopen در دسترس نیست.", diff --git a/includes/installer/i18n/fr.json b/includes/installer/i18n/fr.json index 07b61ccfe1..1c69e65076 100644 --- a/includes/installer/i18n/fr.json +++ b/includes/installer/i18n/fr.json @@ -30,7 +30,8 @@ "Trial", "Tinss", "Thibaut120094", - "Tartare" + "Tartare", + "VIGNERON" ] }, "config-desc": "Le programme d’installation de MediaWiki", @@ -76,11 +77,11 @@ "config-env-bad": "L’environnement a été vérifié.\nVous ne pouvez pas installer MediaWiki.", "config-env-php": "PHP $1 est installé.", "config-env-hhvm": "HHVM $1 est installé.", - "config-unicode-using-intl": "Utilisation de [https://pecl.php.net/intl l’extension PECL intl] pour la normalisation Unicode.", - "config-unicode-pure-php-warning": "Attention : L’[https://pecl.php.net/intl extension PECL intl] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).", + "config-unicode-using-intl": "Utilisation de [https://php.net/manual/en/book.intl.php extension intl de PHP] pour la normalisation Unicode.", + "config-unicode-pure-php-warning": "Attention : L’[https://php.net/manual/en/book.intl.php extension intl de PHP] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).", "config-unicode-update-warning": "Attention : la version installée du normalisateur Unicode utilise une ancienne version de la bibliothèque logicielle du [http://site.icu-project.org/ ''Projet ICU''].\nVous devriez faire une [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations mise à jour] si vous êtes concerné par l’usage d’Unicode.", "config-no-db": "Impossible de trouver un pilote de base de données approprié ! Vous devez installer un pilote de base de données pour PHP. {{PLURAL:$2|Le type suivant|Les types suivants}} de bases de données {{PLURAL:$2|est reconnu|sont reconnus}} : $1.\n\nSi vous avez compilé PHP vous-même, reconfigurez-le avec un client de base de données activé, par exemple en utilisant ./configure --with-mysqli. \nSi vous avez installé PHP depuis un paquet Debian ou Ubuntu, alors vous devrez aussi installer, par exemple, le paquet php-mysql.", - "config-outdated-sqlite": "Attention : vous avez SQLite $1, qui est inférieur à la version minimale requise $2. SQLite sera indisponible.", + "config-outdated-sqlite": "Attention : vous avez SQLite $2, qui est inférieur à la version minimale requise $1. SQLite sera indisponible.", "config-no-fts3": "Attention : SQLite est compilé sans le [//sqlite.org/fts3.html module FTS3] ; les fonctions de recherche ne seront pas disponibles sur ce moteur.", "config-pcre-old": "Erreur fatale : PCRE $1 ou ultérieur est nécessaire.\nVotre binaire PHP est lié avec PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Plus d’information sur PCRE].", "config-pcre-no-utf8": "Erreur fatale : le module PCRE de PHP semble être compilé sans la prise en charge de PCRE_UTF8.\nMediaWiki a besoin de la gestion d’UTF-8 pour fonctionner correctement.", @@ -267,7 +268,7 @@ "config-logo": "URL du logo :", "config-logo-help": "L’habillage par défaut de MediaWiki comprend l’espace pour un logo de 135x160 pixels au-dessus de la barre de menu latérale.\nTéléversez une image de la taille appropriée, et entrez son URL ici.\n\nVous pouvez utiliser $wgStylePath ou $wgScriptPath si votre logo est relatif à ces chemins.\n\nSi vous ne voulez pas de logo, laissez cette case vide.", "config-instantcommons": "Activer ''InstantCommons''", - "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons/fr InstantCommons] est un service qui permet d’utiliser les images, les sons et les autres médias disponibles sur le site [https://meta.wikimedia.org/wiki/Wikimedia_Commons/fr Wikimédia Commons].\nPour ce faire, il faut que MediaWiki accède à Internet.\n\nPour plus d’informations sur ce service, y compris les instructions sur la façon de le configurer pour d’autres wikis que Wikimedia Commons, consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manuel].", + "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons/fr InstantCommons] est un service qui permet d’utiliser les images, les sons et les autres médias disponibles sur le site [https://commons.wikimedia.org Wikimedia Commons].\nPour ce faire, il faut que MediaWiki accède à Internet.\n\nPour plus d’informations sur ce service, y compris les instructions sur la façon de le configurer pour d’autres wikis que Wikimedia Commons, consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manuel].", "config-cc-error": "Le sélection d'une licence ''Creative Commons'' n'a donné aucun résultat.\nEntrez le nom de la licence manuellement.", "config-cc-again": "Choisissez à nouveau...", "config-cc-not-chosen": "Choisissez la licence ''Creative Commons'' que vous désirez et cliquez sur « proceed ».", diff --git a/includes/installer/i18n/ia.json b/includes/installer/i18n/ia.json index 8bf48d8afd..2b85baa7d4 100644 --- a/includes/installer/i18n/ia.json +++ b/includes/installer/i18n/ia.json @@ -51,11 +51,11 @@ "config-env-bad": "Le ambiente ha essite verificate.\nTu non pote installar MediaWiki.", "config-env-php": "PHP $1 es installate.", "config-env-hhvm": "HHVM $1 es installate.", - "config-unicode-using-intl": "Le [https://pecl.php.net/intl extension PECL intl] es usate pro le normalisation Unicode.", - "config-unicode-pure-php-warning": "'''Aviso''': Le [https://pecl.php.net/intl extension PECL intl] non es disponibile pro exequer le normalisation Unicode; le systema recurre al implementation lente in PHP pur.\nSi tu sito ha un alte volumine de traffico, tu deberea informar te un poco super le [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisation Unicode].", + "config-unicode-using-intl": "Le [https://php.net/manual/en/book.intl.php extension PHP intl] es usate pro le normalisation Unicode.", + "config-unicode-pure-php-warning": "'''Aviso''': Le [https://php.net/manual/en/book.intl.php extension PHP intl] non es disponibile pro exequer le normalisation Unicode; le systema recurre al implementation lente in PHP pur.\nSi tu sito ha un alte volumine de traffico, tu deberea informar te super le [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisation Unicode].", "config-unicode-update-warning": "'''Aviso''': Le version installate del bibliotheca inveloppante pro normalisation Unicode usa un version ancian del bibliotheca del [http://site.icu-project.org/ projecto ICU].\nTu deberea [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations actualisar lo] si le uso de Unicode importa a te.", "config-no-db": "Non poteva trovar un driver appropriate pro le base de datos! Es necessari installar un driver de base de datos pro PHP.\nLe sequente {{PLURAL:$2|typo|typos}} de base de datos es supportate: $1.\n\nSi tu compilava PHP tu mesme, reconfigura lo con un cliente de base de datos activate, per exemplo, usante ./configure --with-mysqli.\nSi tu installava PHP ex un pacchetto Debian o Ubuntu, tu debe etiam installar, per exemplo, le modulo php-mysql.", - "config-outdated-sqlite": "'''Attention''': tu ha SQLite $1, que es inferior al version minimal requirite, $2. SQLite essera indisponibile.", + "config-outdated-sqlite": "Attention: tu ha SQLite $2, que es inferior al minime version requirite, $1. SQLite essera indisponibile.", "config-no-fts3": "'''Attention''': SQLite es compilate sin [//sqlite.org/fts3.html modulo FTS3]; functionalitate de recerca non essera disponibile in iste back-end.", "config-pcre-old": "Fatal: PCRE $1 o plus tarde es necessari.\nTu binario de PHP binary es ligate con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Plus information].", "config-pcre-no-utf8": "'''Fatal''': Le modulo PCRE de PHP pare haber essite compilate sin supporto de PCRE_UTF8.\nMediaWiki require supporto de UTF-8 pro functionar correctemente.", diff --git a/includes/installer/i18n/io.json b/includes/installer/i18n/io.json index 4e9781b5dd..f1e441bb4d 100644 --- a/includes/installer/i18n/io.json +++ b/includes/installer/i18n/io.json @@ -10,6 +10,8 @@ "config-title": "Instalo di MediaWiki $1", "config-information": "Informo", "config-localsettings-upgrade": "L'arkivo LocalSettings.php trovesis.\nPor plubonigar l'instaluro, voluntez informar la valoro dil $wgUpgradeKey en l'infra buxo.\nVu trovos ol en LocalSettings.php.", + "config-session-error": "Eroro dum komenco di seciono: $1", + "config-session-expired": "Vua sesiono probable finis.\nSesioni programesis por durar $1\nVu povas augmentar to per modifiko di session.gc_maxlifetime en php.ini.\nRikomencez l'instalo-procedo.", "config-your-language": "Vua idiomo:", "config-your-language-help": "Selektez l'idiomo por uzar dum l'instalo-procedo.", "config-wiki-language": "Wiki linguo:", @@ -38,11 +40,39 @@ "config-env-bad": "Omno verifikesis.\nVu NE POVAS intalar MediaWiki.", "config-env-php": "PHP $1 instalesis.", "config-env-hhvm": "HHVM $1 instalesis.", + "config-unicode-pure-php-warning": "Atencez: La [https://php.net/manual/en/book.intl.php prolonguro PHP intl] ne esas disponebla por traktar skribo-normaligo \"Unicode\". Vice, uzesas la plu lenta laborado en pura PHP.\nSe vu administras pagini multe vizitata, vu mustas lektar la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations skribo-normaligo Unicode].", + "config-memory-raised": "Parametro memory_limit esas $1, modifikata a $2.", + "config-memory-bad": "Atences: la limito por PHP memory_limit esas $1.\nTo probable esas nesuficanta.\nL'instalo-procedo povas faliar!", "config-apc": "[https://www.php.net/apc APC] instalesis", "config-apcu": "[https://www.php.net/apcu APCu] instalesis", + "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] instalesis", + "config-using-uri": "Ret-adreso (URL) dil servero \"$1$2\".", + "config-db-wiki-settings": "Identifikez ca wiki", + "config-db-name": "Nomo dil datumaro (sen strekteti):", + "config-db-install-account": "Konto dil uzero por instalo", + "config-db-username": "Uzero-nomo dil datumaro:", + "config-db-password": "Pasovorto dil datumaro:", + "config-type-mssql": "Microsoft SQL Server", + "config-header-oracle": "Ajusti por Oracle-sistemo:", + "config-header-mssql": "Ajusti por Microsoft SQL Server", + "config-invalid-db-type": "Nevalida tipo di datumaro.", + "config-mysql-myisam": "MyISAM", + "config-ns-generic": "Projeto", + "config-ns-site-name": "Sama kam la wiki-nomo: $1", + "config-ns-other": "Altra (definez precise)", + "config-ns-other-default": "MyWiki", + "config-admin-name": "Vua uzero-nomo:", + "config-admin-password": "Pasovorto:", + "config-admin-password-confirm": "Riskribez la pasovorto:", + "config-admin-email": "E-postal adreso:", + "config-profile-wiki": "Aperta wiki", + "config-profile-no-anon": "Bezonas krear konto", + "config-profile-fishbowl": "Nur permisata redakteri", "config-profile-private": "Privata wiki", + "config-profile-help": "Wikis work best when you let as many people edit them as possible.\nIn MediaWiki, it is easy to review the recent changes, and to revert any damage that is done by naive or malicious users.\n\nHowever, many have found MediaWiki to be useful in a wide variety of roles, and sometimes it is not easy to convince everyone of the benefits of the wiki way.\nSo you have the choice.\n\nThe {{int:config-profile-wiki}} model allows anyone to edit, without even logging in.\nA wiki with {{int:config-profile-no-anon}} provides extra accountability, but may deter casual contributors.\n\nThe {{int:config-profile-fishbowl}} scenario allows approved users to edit, but the public can view the pages, including history.\nA {{int:config-profile-private}} only allows approved users to view pages, with the same group allowed to edit.\n\nMore complex user rights configurations are available after installation, see the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant manual entry].", "config-license": "Autoroyuro e permiso:", "config-license-cc-0": "Creative Commons Zero (Publika domeno)", + "config-license-pd": "Publika domeno", "config-install-step-done": "Facita", "config-install-step-failed": "faliis", "config-install-extensions": "Komplementi inkluzita", diff --git a/includes/installer/i18n/it.json b/includes/installer/i18n/it.json index cc34a31f11..dd9c2ec452 100644 --- a/includes/installer/i18n/it.json +++ b/includes/installer/i18n/it.json @@ -68,11 +68,11 @@ "config-env-bad": "L'ambiente è stato controllato.\nNon è possibile installare MediaWiki.", "config-env-php": "PHP $1 è installato.", "config-env-hhvm": "HHVM $1 è installato.", - "config-unicode-using-intl": "Usa [https://pecl.php.net/intl l'estensione PECL intl] per la normalizzazione Unicode.", - "config-unicode-pure-php-warning": "'''Attenzione:''' [https://pecl.php.net/intl l'estensione PECL intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere alcune considerazioni sulla [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].", + "config-unicode-using-intl": "Usa [https://php.net/manual/en/book.intl.php l'estensione PHP intl] per la normalizzazione Unicode.", + "config-unicode-pure-php-warning": "Attenzione: [https://php.net/manual/en/book.intl.php l'estensione PHP intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].", "config-unicode-update-warning": "'''Attenzione:''' la versione installata del gestore per la normalizzazione Unicode usa una vecchia versione della libreria [http://site.icu-project.org/ del progetto ICU].\nDovresti [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aggiornare] se vuoi usare l'Unicode.", "config-no-db": "Impossibile trovare un driver adatto per il database! È necessario installare un driver per PHP.\n{{PLURAL:$2|Il seguente formato di database è supportato|I seguenti formati di database sono supportati}}: $1.\n\nSe compili PHP autonomamente, riconfiguralo attivando un client database, per esempio utilizzando ./configure --with-mysqli.\nQualora avessi installato PHP per mezzo di un pacchetto Debian o Ubuntu, allora devi installare anche il pacchetto php-mysql.", - "config-outdated-sqlite": "'''Attenzione''': è presente SQLite $1 mentre è richiesta la versione $2, SQLite non sarà disponibile.", + "config-outdated-sqlite": "Attenzione: è presente SQLite $2 mentre è richiesta la versione $1, SQLite non sarà disponibile.", "config-no-fts3": "'''Attenzione''': SQLite è compilato senza il [//sqlite.org/fts3.html modulo FTS3], le funzionalità di ricerca non saranno disponibili su questo backend.", "config-pcre-old": "Errore fatale: si richiede PCRE $1 o successivo.\nIl tuo file binario PHP è collegato con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Maggiori informazioni su PCRE].", "config-pcre-no-utf8": "'''Errore''': Il modulo PCRE di PHP sembra essere stato compilato senza il supporto PCRE_UTF8, ma MediaWiki lo richiede per funzionare correttamente.", diff --git a/includes/installer/i18n/ja.json b/includes/installer/i18n/ja.json index f8318f0c8d..7f7c801e8b 100644 --- a/includes/installer/i18n/ja.json +++ b/includes/installer/i18n/ja.json @@ -71,11 +71,11 @@ "config-env-bad": "環境を確認しました。\nMediaWiki のインストールはできません。", "config-env-php": "PHP $1がインストールされています。", "config-env-hhvm": "HHVM $1 がインストールされています。", - "config-unicode-using-intl": "Unicode正規化に[https://pecl.php.net/intl intl PECL 拡張機能]を使用。", - "config-unicode-pure-php-warning": "警告: Unicode 正規化の処理に [https://pecl.php.net/intl intl PECL 拡張機能]を利用できないため、処理が遅いピュア PHP の実装を代わりに使用しています。\n高トラフィックのサイトを運営する場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode 正規化]をお読みください。", + "config-unicode-using-intl": "Unicode正規化に[https://php.net/manual/en/book.intl.php PHP intl \n 拡張機能]を使用。", + "config-unicode-pure-php-warning": "警告: Unicode 正規化の処理に[https://php.net/manual/en/book.intl.php PHP intl 拡張機能]を利用できないため、処理が遅いピュア PHP の実装を代わりに使用しています。\n高トラフィックのサイトを運営する場合、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode 正規化]は必ず読むよう推奨されます。", "config-unicode-update-warning": "警告: インストールされているバージョンの Unicode 正規化ラッパーは、[http://site.icu-project.org/ ICU プロジェクト]のライブラリの古いバージョンを使用しています。\nUnicode を少しでも利用する可能性がある場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations アップグレード]してください。", "config-no-db": "適切なデータベース ドライバーが見つかりませんでした! PHP にデータベース ドライバーをインストールする必要があります。\n以下の種類のデータベース{{PLURAL:$2|のタイプ}}に対応しています: $1\n\nPHP を自分でコンパイルした場合は、例えば ./configure --with-mysqli を実行して、データベース クライアントを使用できるように再設定してください。\nDebian または Ubuntu のパッケージから PHP をインストールした場合は、モジュール (例: php-mysql) もインストールする必要があります。", - "config-outdated-sqlite": "警告: あなたは SQLite $1 を使用していますが、最低限必要なバージョン $2 より古いバージョンです。SQLite は利用できません。", + "config-outdated-sqlite": "警告: ご利用の SQLite $2 は容認されている最古の版 $1 よりも古い版です。SQLite が対応しません。", "config-no-fts3": "警告: SQLite は [//sqlite.org/fts3.html FTS3] モジュールなしでコンパイルされており、このバックエンドでは検索機能は利用できなくなります。", "config-pcre-old": "致命的エラー: PCRE $1 以降が必要です。\nご使用中の PHP のバイナリは PCRE $2 とリンクされています。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 詳細情報]", "config-pcre-no-utf8": "致命的エラー: PHP の PCRE が PCRE_UTF8 対応なしでコンパイルされているようです。\nMediaWiki を正しく動作させるには、UTF-8 対応が必要です。", @@ -154,7 +154,7 @@ "config-invalid-db-server-oracle": "「$1」は無効なデータベース TNS です。\n「TNS 名」「Easy Connect」文字列のいずれかを使用してください ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle ネーミング メソッド])。", "config-invalid-db-name": "「$1」は無効なデータベース名です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。", "config-invalid-db-prefix": "「$1」は無効なデータベース接頭辞です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。", - "config-connection-error": "$1。\n\n以下のホスト名、ユーザー名、パスワードを確認してから再度試してください。", + "config-connection-error": "$1。\n\n以下のホスト名、ユーザー名、パスワードを確認してから再度試してください。データベースホストとして「localhost」を使用している場合は、代わりに 「127.0.0.1」を使用してください(またはその逆)。", "config-invalid-schema": "「$1」は MediaWiki のスキーマとして無効です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_) のみを使用してください。", "config-db-sys-create-oracle": "インストーラーは、新規アカウント作成にはSYSDBAアカウントの利用のみをサポートしています。", "config-db-sys-user-exists-oracle": "利用者アカウント「$1」は既に存在します。SYSDBA は新しいアカウントの作成のみに使用できます!", @@ -243,7 +243,7 @@ "config-license-help": "多くの公開ウィキでは、すべての寄稿物が[https://freedomdefined.org/Definition フリーライセンス]のもとに置かれています。\nこうすることにより、コミュニティによる共有の感覚が生まれ、長期的な寄稿が促されます。\n私的ウィキや企業のウィキでは、通常、フリーライセンスにする必要はありません。\n\nウィキペディアにあるテキストをあなたのウィキで利用し、逆にあなたのウィキにあるテキストをウィキペディアに複製することを許可したい場合には、{{int:config-license-cc-by-sa}}を選択するべきです。\n\nウィキペディアは以前、GNUフリー文書利用許諾契約書(GFDL)を使用していました。\nGFDLは有効なライセンスですが、内容を理解するのは困難です。\nまた、GFDLのもとに置かれているコンテンツの再利用も困難です。", "config-email-settings": "メールの設定", "config-enable-email": "メール送信を有効にする", - "config-enable-email-help": "メールを使用したい場合は、[Config-dbsupport-oracle/manual/en/mail.configuration.php PHP のメール設定]が正しく設定されている必要があります。\nメールの機能を使用しない場合は、ここで無効にすることができます。", + "config-enable-email-help": "メールを使用したい場合は、[https://www.php.net/manual/en/mail.configuration.php PHP のメール設定]が正しく設定されている必要があります。\nメールの機能を使用しない場合は、ここで無効にすることができます。", "config-email-user": "利用者間のメールを有効にする", "config-email-user-help": "設定で有効になっている場合、すべてのユーザーがお互いにメールのやりとりを行うことを許可する。", "config-email-usertalk": "利用者のトークページでの通知を有効にする", @@ -326,6 +326,7 @@ "config-install-done": "おめでとうございます!\nMediaWikiのインストールに成功しました。\n\nLocalSettings.phpファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、ウィキをインストールした基準ディレクトリ (index.phpと同じディレクトリ) に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n注意: この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、[$2 ウィキに入る]ことができます。", "config-install-done-path": "おめでとうございます!\nMediaWikiのインストールに成功しました。\n\nLocalSettings.phpファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、$4 に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n注意: この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、[$2 ウィキに入る]ことができます。", "config-install-success": "MediaWikiが正常にインストールされました。\n今すぐ<$1$2>にアクセスしてあなたのwikiを表示できます。\nご質問がある場合は、よくある質問リストをご覧ください:\nまたは\nそのページにリンクされているサポートフォーラム", + "config-install-db-success": "データベースは正常にセットアップされました", "config-download-localsettings": "LocalSettings.php をダウンロード", "config-help": "ヘルプ", "config-help-tooltip": "クリックで展開", diff --git a/includes/installer/i18n/ko.json b/includes/installer/i18n/ko.json index dca5ef8791..f952045cc3 100644 --- a/includes/installer/i18n/ko.json +++ b/includes/installer/i18n/ko.json @@ -63,7 +63,7 @@ "config-unicode-pure-php-warning": "경고: 유니코드 정규화를 처리할 [https://pecl.php.net/intl intl PECL 확장 기능]을 사용할 수 없기 때문에 느린 pure-PHP 구현을 대신 사용합니다.\n트래픽이 높은 사이트에서 실행하시려면 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 유니코드 정규화]를 읽어보셔야 합니다.", "config-unicode-update-warning": "경고: 유니코드 정규화 래퍼의 설치된 버전은 [http://site.icu-project.org/ ICU 프로젝트]의 라이브러리의 이전 버전을 사용합니다.\n만약 유니코드를 사용하는 것에 대해 우려가 된다면 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 업그레이드]해야합니다.", "config-no-db": "적절한 데이터베이스 드라이버를 찾을 수 없습니다! PHP용 데이터베이스 드라이버를 설치해야 합니다.\n다음 데이터베이스 {{PLURAL:$2|유형을}} 지원합니다: $1.\n\nPHP를 직접 컴파일했다면, 예를 들어 ./configure --with-mysqli을 사용하여, 데이터베이스 클라이언트를 활성화하도록 다시 설정하세요.\n데비안이나 우분투 패키지에서 PHP를 설치했다면 php-mysql 패키지도 설치해야 합니다.", - "config-outdated-sqlite": "경고: 최소 요구 버전 $2 보다 낮은 SQLite $1이(가) 있습니다. SQLite를 사용할 수 없습니다.", + "config-outdated-sqlite": "경고: 최소 요구 버전 $1 보다 낮은 SQLite $2이(가) 있습니다. SQLite를 사용할 수 없습니다.", "config-no-fts3": "경고: SQLite를 [//sqlite.org/fts3.html FTS3 모듈] 없이 컴파일하며, 검색 기능은 백엔드에 사용할 수 없습니다.", "config-pcre-old": "치명: PCRE $1 또는 그 이상이 필요합니다.\nPHP 바이너리는 PCRE $2에 연결되어 있습니다. [https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 자세한 정보].", "config-pcre-no-utf8": "치명: PHP의 PCRE 모듈은 RCRE_UTF8 지원 없이 컴파일된 것 같습니다.\n미디어위키가 올바르게 작동하려면 UTF-8을 지원해야 합니다.", diff --git a/includes/installer/i18n/lb.json b/includes/installer/i18n/lb.json index bbe1a1d200..88d13fe31d 100644 --- a/includes/installer/i18n/lb.json +++ b/includes/installer/i18n/lb.json @@ -47,7 +47,7 @@ "config-env-php": "PHP $1 ass installéiert.", "config-env-hhvm": "HHVM $1 ass installéiert.", "config-no-db": "Et konnt kee passenden Datebank-Driver fonnt ginn! Dir musst een Datebank-Driver fir PHP installéieren.\n{{PLURAL:$2|Dësn Datebank-Typ gëtt|Dës Datebank-Type ginn}} ënnerstëtzt: $1.\n\nWann Dir PHP selwer compiléiert hutt, da rekonfiguréiert en mat dem ageschalten Datebank-Client, zum Beispill an deem Dir ./configure --with-mysqli benotzt.\nWann Dir PHP vun engem Debian oder Ubuntu Package aus installéiert hutt, da musst Dir och den php5-mysql Modul installéieren.", - "config-outdated-sqlite": "'''Warnung:''' SQLite $1 ass installéiert. Allerdengs brauch MediaWiki SQLite $2 oder méi nei. SQLite ass dofir net disponibel.", + "config-outdated-sqlite": "Warnung: SQLite $2 ass installéiert. Allerdengs brauch MediaWiki SQLite $1 oder méi nei. SQLite ass dofir net disponibel.", "config-memory-bad": "'''Opgepasst:''' De Parameter memory_limit vu PHP ass $1.\nDat ass wahrscheinlech ze niddreg.\nD'Installatioun kéint net funktionéieren.", "config-apc": "[https://www.php.net/apc APC] ass installéiert", "config-apcu": "[https://www.php.net/apcu APCu] ass installéiert.", diff --git a/includes/installer/i18n/nb.json b/includes/installer/i18n/nb.json index 8dec5665cf..fc299fb91c 100644 --- a/includes/installer/i18n/nb.json +++ b/includes/installer/i18n/nb.json @@ -70,9 +70,9 @@ "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] er installert", "config-no-cache-apcu": "Advarsel: Kunne ikke finne [https://www.php.net/apcu APCu] eller [https://www.iis.net/downloads/microsoft/wincache-extension WinCache].\nObjekthurtiglagring er ikke aktivert.", "config-mod-security": "'''Advarsel''': Din web-tjener har [https://modsecurity.org/ mod_security] påslått. Hvis denne er feilinnstilt, kan det gi problemer for MediaWiki eller annen programvare som tillater brukere å poste vilkårlig innhold.\nSjekk [https://modsecurity.org/documentation/ mod_security-dokumentasjonen] eller ta kontakt med din nettleverandør hvis du opplever tilfeldige feil.", - "config-diff3-bad": "GNU diff3 ikke funnet.", + "config-diff3-bad": "GNU diff3 ikke funnet. Du kan ignorere dette for øyeblikket, men du kan støte på redigeringskonflikter oftere.", "config-git": "Har funnet Git version control software: $1.", - "config-git-bad": "Git version control software ble ikke funnet.", + "config-git-bad": "Git version control software ble ikke funnet. Du kan ignorere dette for øyeblikket. Merk at Special:Version ikke vil vise hashverdien for commits.", "config-imagemagick": "Fant ImageMagick: $1.\nBildeminiatyrisering vil aktiveres om du aktiverer opplastinger.", "config-gd": "Fant innebygd GD-grafikkbibliotek.\nBildeminiatyrisering vil aktiveres om du aktiverer opplastinger.", "config-no-scaling": "Kunne ikke finne GD-bibliotek eller ImageMagick.\nBildeminiatyrisering vil være deaktivert.", @@ -87,11 +87,11 @@ "config-using-32bit": "Adversel: Systemet ditt ser ut til å være 32-bit-basert, mens dette er [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit not advised].", "config-db-type": "Databasetype:", "config-db-host": "Databasevert:", - "config-db-host-help": "Hvis databasen kjører på en annen tjenermaskin, skriv inn vertsnavnet eller IP-adressen her.\n\nHvis du bruker et webhotell, vil du kunne be om aktuelt vertsnavn fra din leverandør.\n\nHvis du installerer på en Windowstjener og bruker MySQL, kan det hende at «localhost» ikke brukes som tjenernavn. Hvis så er tilfelle, prøv «127.0.0.1» som lokal IP-adresse.\n\nHvis du bruker PostgreSQL, la dette feltet være blankt slik at koplingen gjøres via en \"Unix socket\".", + "config-db-host-help": "Hvis databasen kjører på en annen tjenermaskin, skriv inn vertsnavnet eller IP-adressen her.\n\nHvis du bruker et webhotell, vil du kunne be om aktuelt vertsnavn fra din leverandør.\n\nHvis du bruker MySQL, kan det hende at «localhost» ikke brukes som tjenernavn. Hvis så er tilfelle, prøv «127.0.0.1» som lokal IP-adresse.\n\nHvis du bruker PostgreSQL, la dette feltet være blankt slik at koplingen gjøres via en \"Unix socket\".", "config-db-host-oracle": "Database TNS:", "config-db-host-oracle-help": "Skriv inn et gyldig [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; en tnsnames.ora-fil må være synlig for installasjonsprosessen.
Hvis du bruker klientbibliotek 10g eller nyere kan du også bruke navngivingsmetoden [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].", "config-db-wiki-settings": "Identifiser denne wikien", - "config-db-name": "Databasenavn:", + "config-db-name": "Databasenavn (ingen bindestreker):", "config-db-name-help": "Velg et navn som identifiserer wikien din.\nDet bør ikke inneholde mellomrom.\n\nHvis du bruker en delt nettvert vil verten din enten gi deg et spesifikt databasenavn å bruke, eller la deg opprette databaser via kontrollpanelet.", "config-db-name-oracle": "Databaseskjema:", "config-db-account-oracle-warn": "Det finnes tre mulig fremgangsmåter for å installere Oracle som database:\n\nHvis du ønsker å opprette en databasekonto som del av installasjonsprosessen, oppgi da en konto med SYSDBA-rolle som databasekonto for installasjonen og angi påkrevd autentiseringsinformasjon for web-aksesskontoen. Ellers kan du enten opprette web-aksesskontoen manuelt eller kun oppgi den kontoen (hvis den har påkrevede tillatelser for å opprette skjemeobjektene) , alternativt oppgi to ulike kontoer, en med opprettelsesprivilegier (create) og en begrenset konto for web-aksess.\n\nSkript for å opprette en konto med påkrevde privilegier finnes i \"maintenance/oracle/\"-folderen av denne installasjonen. Husk at det å bruke en begrenset konto vil blokkere all vedlikeholdsfunksjonalitet med standard konto.", @@ -104,11 +104,11 @@ "config-db-account-lock": "Bruk det samme brukernavnet og passordet under normal drift", "config-db-wiki-account": "Brukerkonto for normal drift", "config-db-wiki-help": "Skriv inn brukernavnet og passordet som vil bli brukt til å koble til databasen under normal wikidrift.\nHvis kontoen ikke finnes, og installasjonskontoen har tilstrekkelige privilegier, vil denne brukerkontoen bli opprettet med et minimum av privilegier, tilstrekkelig for å operere wikien.", - "config-db-prefix": "Databasetabellprefiks:", + "config-db-prefix": "Databasetabellprefiks (ingen bindestreker):", "config-db-prefix-help": "Hvis du trenger å dele en database mellom flere wikier, eller mellom MediaWiki og andre nettapplikasjoner, kan du velge å legge til et prefiks til alle tabellnavnene for å unngå konflikter.\nIkke bruk mellomrom.\n\nDette feltet er vanligvis tomt.", "config-mysql-old": "MySQL $1 eller senere kreves, du har $2.", "config-db-port": "Databaseport:", - "config-db-schema": "Skjema for MediaWiki", + "config-db-schema": "Skjema for MediaWiki (ingen bindestreker):", "config-db-schema-help": "Dette skjemaet er som regel riktig.\nBare endre det hvis du vet at du trenger det.", "config-pg-test-error": "Får ikke kontakt med database '''$1''': $2", "config-sqlite-dir": "SQLite datamappe:", @@ -138,7 +138,7 @@ "config-invalid-db-server-oracle": "Ugyldig database-TNS «$1».\nBruk enten \"TNS Name\" eller en \"Easy Connect\"-streng ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])", "config-invalid-db-name": "Ugyldig databasenavn «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9), undestreker (_) og bindestreker (-).", "config-invalid-db-prefix": "Ugyldig databaseprefiks «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9), undestreker (_) og bindestreker (-).", - "config-connection-error": "$1.\n\nSjekk verten, brukernavnet og passordet nedenfor og prøv igjen.", + "config-connection-error": "$1.\n\nSjekk verten, brukernavnet og passordet nedenfor og prøv igjen. Hvis du brukte «localhost» som databasevert, prøv å bruke «127.0.0.1» i stedet (eller motsatt).", "config-invalid-schema": "Ugyldig skjema for MediaWiki «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9) og undestreker (_).", "config-db-sys-create-oracle": "Installasjonsprogrammet støtter kun bruk av en SYSDBA-konto for opprettelse av en ny konto.", "config-db-sys-user-exists-oracle": "Brukerkontoen «$1» finnes allerede. SYSDBA kan kun brukes for oppretting av nye kontoer!", @@ -154,6 +154,7 @@ "config-sqlite-cant-create-db": "Kunne ikke opprette databasefilen $1.", "config-sqlite-fts3-downgrade": "PHP mangler FTS3-støtte, nedgraderer tabeller", "config-can-upgrade": "Det er MediaWiki-tabeller i denne databasen.\nFor å oppgradere dem til MediaWiki $1, klikk '''Fortsett'''.", + "config-upgrade-error": "En feil oppsto under oppgradering av MediaWiki-tabeller i databasen din.\n\nFor mer informasjon, se på loggen ovenfor; klikk Fortsett for å prøve igjen.", "config-upgrade-done": "Oppgradering fullført.\n\nDu kan nå [$1 begynne å bruke wikien din].\n\nHvis du ønsker å regenerere LocalSettings.php-filen din, klikk på knappen nedenfor.\nDette er '''ikke anbefalt''' med mindre du har problemer med wikien din.", "config-upgrade-done-no-regenerate": "Oppgradering fullført.\n\nDu kan nå [$1 begynne å bruke wikien din].", "config-regenerate": "Regenerer LocalSettings.php →", @@ -309,6 +310,7 @@ "config-install-done": "Gratulrerer!\nDu har lykkes i å installere MediaWiki.\n\nInstallasjonsprogrammet har generert en LocalSettings.php-fil.\nDen inneholder alle dine konfigureringer.\n\nDu må laste den ned og legge den på hovedfolderen for din wiki-installasjon (der index.php ligger). Nedlastingen skulle ha startet automatisk.\n\nHvis ingen nedlasting ble tilbudt, eller du avbrøt den, kan du få den i gang ved å klikke på lenken under:\n\n$3\n\nOBS: Hvis du ikke gjør dette nå, vil den genererte konfigurasjonsfilen ikke være tilgjengelig for deg senere.\n\nNår dette er gjort, kan du [$2 gå inn i wikien].", "config-install-done-path": "Gratulerer!\nDu har installert MediaWiki.\n\nInstallereren har generert en LocalSettings.php-fil.\nDen inneholder all konfigurasjonen for wikien.\n\nDu må laste den ned og legge den i $4. Nedlastingen skal ha startet automatisk.\n\nOm nedlastingen ikke ble startet, eller om du avbrøt den, kan du starte på nytt ved å klikke lenken nedenfor:\n\n$3\n\nMerk: Om du ikke gjør dette nå vil den genererte konfigurasjonen ikke være tilgjengelig senere.\n\nNår dette er gjort kan du [$2 gå til wikien din].", "config-install-success": "MediaWiki har blitt installert. Du kan nå\nbesøke <$1$2> for å se wikien din.\nOm du har spørsmål, sjekk de ofte stilte spørsmålene:\n eller bruk et av\nsupportforumene som lenkes til fra den siden.", + "config-install-db-success": "Databasen ble satt opp", "config-download-localsettings": "Last ned LocalSettings.php", "config-help": "hjelp", "config-help-tooltip": "klikk for å utvide", diff --git a/includes/installer/i18n/nl.json b/includes/installer/i18n/nl.json index a12445cc28..6a62fb667f 100644 --- a/includes/installer/i18n/nl.json +++ b/includes/installer/i18n/nl.json @@ -69,11 +69,11 @@ "config-env-bad": "De omgeving is gecontroleerd.\nU kunt MediaWiki niet installeren.", "config-env-php": "PHP $1 is geïnstalleerd.", "config-env-hhvm": "HHVM $1 is geïnstalleerd.", - "config-unicode-using-intl": "Voor Unicode-normalisatie wordt de [https://pecl.php.net/intl PECL-extensie intl] gebruikt.", - "config-unicode-pure-php-warning": "Waarschuwing: de [https://pecl.php.net/intl PECL-extensie intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzamere PHP-implementatie gebruikt.\nAls u MediaWiki voor een website met veel verkeer installeert, lees u dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].", + "config-unicode-using-intl": "Voor Unicode-normalisatie wordt de [https://php.net/manual/en/book.intl.php PHP-extensie intl] gebruikt.", + "config-unicode-pure-php-warning": "Waarschuwing: de [https://php.net/manual/en/book.intl.php PHP-uitbreiding intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzamere PHP-implementatie gebruikt.\nAls u MediaWiki voor een website met veel verkeer installeert, lees u dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].", "config-unicode-update-warning": "Waarschuwing: de geïnstalleerde versie van de Unicodenormalisatiewrapper maakt gebruik van een oudere versie van [http://site.icu-project.org/ de bibliotheek van het ICU-project].\nU moet [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations bijwerken] als Unicode voor u van belang is.", "config-no-db": "Het was niet mogelijk een geschikte databasedriver te vinden voor PHP! U moet een databasedriver installeren voor PHP.\n{{PLURAL:$2|Het volgende databasetype wordt|De volgende databasetypes worden}} ondersteund: $1.\n\nAls u PHP zelf hebt gecompileerd, wijzig dan uw instellingen zodat een databasedriver wordt geactiveerd, bijvoorbeeld via ./configure --with-mysqli.\nAls u PHP hebt geïnstalleerd via een Debian- of Ubuntu-package, installeer dan ook bijvoorbeeld de module php-mysql.", - "config-outdated-sqlite": "''' Waarschuwing:''' u gebruikt SQLite $1. SQLite is niet beschikbaar omdat de minimaal vereiste versie $2 is.", + "config-outdated-sqlite": "''' Waarschuwing:''' u gebruikt SQLite $2. SQLite is niet beschikbaar omdat de minimaal vereiste versie $1 is.", "config-no-fts3": "Waarschuwing: SQLite is gecompileerd zonder de module [//sqlite.org/fts3.html FTS3]; zoekfuncties zijn niet beschikbaar.", "config-pcre-old": "'''Onherstelbare fout:''' PCRE $1 of een latere versie is vereist.\nUw uitvoerbare versie van PHP is gekoppeld met PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Meer informatie].", "config-pcre-no-utf8": "Onherstelbare fout: de module PRCE van PHP lijkt te zijn gecompileerd zonder ondersteuning voor PCRE_UTF8.\nMediaWiki heeft ondersteuning voor UTF-8 nodig om correct te kunnen werken.", diff --git a/includes/installer/i18n/olo.json b/includes/installer/i18n/olo.json index dd24138607..6a518c75e3 100644 --- a/includes/installer/i18n/olo.json +++ b/includes/installer/i18n/olo.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Mashoi7", - "Ilja.mos" + "Ilja.mos", + "Pyscowicz" ] }, "config-your-language": "Sinun kieli:", @@ -28,7 +29,7 @@ "config-db-username": "Tiedokannan käyttäinimi:", "config-db-password": "Tiedokannan salasana:", "config-type-mssql": "Microsoft SQL Server", - "config-header-mysql": "MySQL-azetukset", + "config-header-mysql": "MariaDB/MySQL-azetukset", "config-header-postgres": "PostgreSQL-azetukset", "config-header-sqlite": "SQLite-azetukset", "config-header-oracle": "Oracle-azetukset", diff --git a/includes/installer/i18n/pl.json b/includes/installer/i18n/pl.json index 41da9dc5ec..18dc924536 100644 --- a/includes/installer/i18n/pl.json +++ b/includes/installer/i18n/pl.json @@ -70,11 +70,11 @@ "config-env-bad": "Środowisko oprogramowania zostało sprawdzone.\nNie możesz zainstalować MediaWiki.", "config-env-php": "Zainstalowane jest PHP w wersji $1.", "config-env-hhvm": "Zainstalowany jest HHVM $1.", - "config-unicode-using-intl": "Korzystanie z [https://pecl.php.net/intl rozszerzenia intl PECL] do normalizacji Unicode.", - "config-unicode-pure-php-warning": "Uwaga: [https://pecl.php.net/intl Rozszerzenie intl PECL] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].", + "config-unicode-using-intl": "Korzystanie z [https://php.net/manual/en/book.intl.php rozszerzenia PHP intl] do normalizacji Unicode.", + "config-unicode-pure-php-warning": "Uwaga: [https://php.net/manual/en/book.intl.php rozszerzenie PHP intl] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].", "config-unicode-update-warning": "Uwaga: zainstalowana wersja normalizacji Unicode korzysta z nieaktualnej biblioteki [http://site.icu-project.org/ projektu ICU].\nPowinieneś [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations wykonać aktualizację], jeśli chcesz korzystać w pełni z Unicode.", "config-no-db": "Nie można odnaleźć właściwego sterownika bazy danych! Musisz zainstalować sterownik bazy danych dla PHP.\nMożna użyć {{PLURAL:$2|następującego typu bazy|następujących typów baz}} danych: $1.\n\nJeśli skompilowałeś PHP samodzielnie, skonfiguruj go ponownie z włączonym klientem bazy danych, na przykład za pomocą polecenia ./configure --with-mysqli.\nJeśli zainstalowałeś PHP jako pakiet Debiana lub Ubuntu, musisz również zainstalować np. moduł php-mysql.", - "config-outdated-sqlite": "'''Ostrzeżenie''': masz SQLite $1, która jest niższa od minimalnej wymaganej wersji $2 . SQLite będzie niedostępne.", + "config-outdated-sqlite": "Ostrzeżenie: masz SQLite $2, która jest niższa od minimalnej wymaganej wersji $1 . SQLite będzie niedostępne.", "config-no-fts3": "'''Uwaga''' – SQLite został skompilowany bez [//sqlite.org/fts3.html modułu FTS3] – funkcje wyszukiwania nie będą dostępne.", "config-pcre-old": "Błąd krytyczny: Wymagany jest PCRE w wersji $1 lub nowszej.\nTwój plik wykonywalny PHP jest powiązany z wersją PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Więcej informacji].", "config-pcre-no-utf8": "'''Błąd krytyczny''' – wydaje się, że moduł PCRE w PHP został skompilowany bez wsparcia dla UTF‐8.\nMediaWiki wymaga wsparcia dla UTF‐8 do prawidłowego działania.", @@ -87,7 +87,7 @@ "config-mod-security": "''' Ostrzeżenie ''': Serwer sieci web ma włączone [https://modsecurity.org/ mod_security]. Jeśli jest niepoprawnie skonfigurowane, może być przyczyną problemów MediaWiki lub innego oprogramowania, które pozwala użytkownikom na wysyłanie dowolnej zawartości.\nSprawdź w [https://modsecurity.org/documentation/ dokumentacji mod_security] lub skontaktuj się z obsługa hosta, jeśli wystąpią losowe błędy.", "config-diff3-bad": "Nie znaleziono funkcjonalności porównywania tekstu GNU diff3. Możesz zignorować ten komunikat, jednak konflikty edycji będą wówczas częstsze.", "config-git": "Znaleziono oprogramowanie kontroli wersji Git: $1.", - "config-git-bad": "Oprogramowanie systemu kontroli wersji Git nie zostało znalezione.", + "config-git-bad": "Oprogramowanie systemu kontroli wersji Git nie zostało znalezione. Możesz zignorować ten komunikat, jednak numery commitów nie będą wyświetlane na stronie Specjalna:Wersja.", "config-imagemagick": "Mamy zainstalowany ImageMagick $1, dzięki czemu będzie można pomniejszać załadowane grafiki.", "config-gd": "Mamy wbudowaną bibliotekę graficzną GD, dzięki czemu będzie można pomniejszać załadowane grafiki.", "config-no-scaling": "Nie odnaleziono biblioteki GD lub ImageMagick. Możliwość zmniejszania załadowywanych grafik zostanie wyłączona.", diff --git a/includes/installer/i18n/pt-br.json b/includes/installer/i18n/pt-br.json index 2bae20f731..e9bb22be67 100644 --- a/includes/installer/i18n/pt-br.json +++ b/includes/installer/i18n/pt-br.json @@ -69,11 +69,11 @@ "config-env-bad": "O ambiente foi verificado.\nVocê não pode instalar o MediaWiki.", "config-env-php": "O PHP $1 está instalado.", "config-env-hhvm": "O HHVM $1 está instalado.", - "config-unicode-using-intl": "Usando a [https://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.", - "config-unicode-pure-php-warning": "Aviso: A [https://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].", + "config-unicode-using-intl": "Usando a [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] para a normalização Unicode.", + "config-unicode-pure-php-warning": "Aviso: A [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].", "config-unicode-update-warning": "Aviso: A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://www.site.icu-project.org/projeto ICU].\nVocê deve [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizar] se você tem quaisquer preocupações com o uso do Unicode.", "config-no-db": "Não foi possível encontrar um driver apropriado para a banco de dados! Você precisa instalar um driver de banco de dados para PHP. {{PLURAL:$2|É aceito o seguinte tipo|São aceitos os seguintes tipos}} de banco de dados: $1.\n\nSe você compilou o PHP, reconfigure-o com um cliente de banco de dados ativado, por exemplo, usando ./configure --with-mysqli.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então também precisa instalar, por exemplo, o pacote php-mysql.", - "config-outdated-sqlite": "Aviso: você tem o SQLite versão $1, que é menor do que a versão mínima necessária $2. O SQLite não estará disponível.", + "config-outdated-sqlite": "Aviso: você tem o SQLite versão $2, que é menor do que a versão mínima necessária $1. O SQLite não estará disponível.", "config-no-fts3": "Aviso O SQLite foi compilado sem o [//sqlite.org/fts3.html módulo FTS3], as funcionalidades de pesquisa não estarão disponíveis nesta instalação.", "config-pcre-old": "Erro fatal: É necessário o PCRE $1 ou versão posterior.\nO binário do seu PHP foi vinculado com o PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mais informações].", "config-pcre-no-utf8": "Erro fatal: O módulo PCRE do PHP parece ser compilado sem suporte a PCRE_UTF8.\nO MediaWiki requer suporte a UTF-8 para funcionar corretamente.", diff --git a/includes/installer/i18n/pt.json b/includes/installer/i18n/pt.json index 8768afc536..6acd86624b 100644 --- a/includes/installer/i18n/pt.json +++ b/includes/installer/i18n/pt.json @@ -21,7 +21,8 @@ "Seb35", "MokaAkashiyaPT", "Athena in Wonderland", - "CaiusSPQR" + "CaiusSPQR", + "Waldyrious" ] }, "config-desc": "O instalador do MediaWiki", diff --git a/includes/installer/i18n/qqq.json b/includes/installer/i18n/qqq.json index 91f07c319f..ceb7586c42 100644 --- a/includes/installer/i18n/qqq.json +++ b/includes/installer/i18n/qqq.json @@ -22,7 +22,8 @@ "Metalhead64", "Tacsipacsi", "Zoranzoki21", - "BadDog" + "BadDog", + "Waldyrious" ] }, "config-desc": "Short description of the installer.", @@ -72,7 +73,7 @@ "config-unicode-pure-php-warning": "PECL is the name of a group producing standard pieces of software for PHP, and intl is the name of their library handling some aspects of internationalization.", "config-unicode-update-warning": "ICU is a body producing standard software tools for support of Unicode and other internationalization aspects. This message warns the system administrator installing MediaWiki that the server's software is not up-to-date and MediaWiki will have problems handling some characters.", "config-no-db": "{{doc-important|Do not translate \"./configure --with-mysqli\" and \"php-mysql\".}}\nParameters:\n* $1 is comma separated list of database types supported by MediaWiki.\n* $2 is the count of items in $1 - for use in plural.", - "config-outdated-sqlite": "Used as warning. Parameters:\n* $1 - the version of SQLite that has been installed\n* $2 - minimum version", + "config-outdated-sqlite": "Used as warning. Parameters:\n* $2 - the version of SQLite that has been installed\n* $1 - minimum version", "config-no-fts3": "A \"[[:wikipedia:Front and back ends|backend]]\" is a system or component that ordinary users don't interact with directly and don't need to know about, and that is responsible for a distinct task or service - for example, a storage back-end is a generic system for storing data which other applications can use. Possible alternatives for back-end are \"system\" or \"service\", or (depending on context and language) even leave it untranslated.", "config-pcre-old": "Parameters:\n* $1 - minimum PCRE version number\n* $2 - the installed version of [[wikipedia:PCRE|PCRE]]\n{{Related|Config-fatal}}", "config-pcre-no-utf8": "PCRE is a name of a programmers' library for supporting regular expressions. It can probably be translated without change.\n{{Related|Config-fatal}}", diff --git a/includes/installer/i18n/ro.json b/includes/installer/i18n/ro.json index dca1df5f90..66eca85ffa 100644 --- a/includes/installer/i18n/ro.json +++ b/includes/installer/i18n/ro.json @@ -9,7 +9,8 @@ "Strainu", "Fitoschido", "WebSourceContentRO", - "MSClaudiu" + "MSClaudiu", + "Andrei Stroe" ] }, "config-desc": "Programul de instalare pentru MediaWiki", @@ -79,7 +80,7 @@ "config-db-host": "Gazdă bază de date:", "config-db-host-oracle": "Baza de date TNS:", "config-db-wiki-settings": "Identificați acest wiki", - "config-db-name": "Numele bazei de date:", + "config-db-name": "Numele bazei de date (fără cratime):", "config-db-name-oracle": "Schema bazei de date:", "config-db-install-account": "Contul de utilizator pentru instalare", "config-db-username": "Nume de utilizator pentru baza de date:", @@ -89,15 +90,15 @@ "config-db-install-help": "Introduceți numele de utilizator și parola care vor fi utilizate pentru conexiunea la baza de date în timpul procesului de instalare.", "config-db-account-lock": "Folosește același nume de utilizator și parolă în timpul funcționării normale", "config-db-wiki-account": "Contul de utilizator pentru funcționarea normală", - "config-db-prefix": "Prefixul tabelelor din baza de date:", + "config-db-prefix": "Prefixul tabelelor din baza de date (fără cratime):", "config-db-port": "Portul bazei de date:", - "config-db-schema": "Schema pentru MediaWiki:", + "config-db-schema": "Schema pentru MediaWiki (fără cratime):", "config-sqlite-dir": "Director de date SQLite:", "config-oracle-def-ts": "Spațiu de stocare („tablespace”) implicit:", "config-oracle-temp-ts": "Spațiu de stocare („tablespace”) temporar:", "config-type-mysql": "MariaDB, MySQL sau compatibil", "config-type-mssql": "Microsoft SQL Server", - "config-header-mysql": "Setările MySQL", + "config-header-mysql": "Setările MariaDB/MySQL", "config-header-postgres": "Setări PostgreSQL", "config-header-sqlite": "Setări SQLite", "config-header-oracle": "Setări Oracle", @@ -106,14 +107,14 @@ "config-missing-db-name": "Trebuie să introduceți o valoare pentru „{{int:config-db-name}}”.", "config-missing-db-host": "Trebuie să introduceți o valoare pentru „{{int:config-db-host}}”.", "config-missing-db-server-oracle": "Trebuie să introduceți o valoare pentru „{{int:config-db-host-oracle}}”.", - "config-connection-error": "$1.\n\nVerificați gazda, numele de utilizator și parola și reîncercați.", + "config-connection-error": "$1.\n\nVerificați hostul, numele de utilizator și parola și reîncercați. Dacă folosiți „localhost” drept host al bazei de date, încercați mai bine „127.0.0.1” (sau invers).", "config-upgrade-done-no-regenerate": "Actualizare completă.\n\nAcum puteți [$1 începe să vă folosiți wikiul].", "config-regenerate": "Regenerare LocalSettings.php →", "config-unknown-collation": "AVERTISMENT: Baza de date folosește o colaționare nerecunoscută.", "config-db-web-account": "Contul bazei de date pentru accesul web.", "config-db-web-create": "Creați contul dacă nu există deja", "config-mysql-engine": "Motor de stocare:", - "config-mysql-innodb": "InnoDB", + "config-mysql-innodb": "InnoDB (recomandat)", "config-mysql-myisam": "MyISAM", "config-mssql-auth": "Tip de autentificare:", "config-site-name": "Numele wikiului:", @@ -176,6 +177,6 @@ "config-download-localsettings": "Descarcă LocalSettings.php", "config-help": "ajutor", "config-help-tooltip": "clic pentru a extinde", - "mainpagetext": "'''Programul Wiki a fost instalat cu succes.'''", - "mainpagedocfooter": "Consultați [https://meta.wikimedia.org/wiki/Help:Contents Ghidul utilizatorului (en)] pentru informații despre utilizarea software-ului wiki.\n\n== Primii pași ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista parametrilor configurabili (en)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Întrebări frecvente despre MediaWiki (en)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discuții a MediaWiki (en)]" + "mainpagetext": "Programul Wiki a fost instalat.", + "mainpagedocfooter": "Consultați [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Ghidul utilizatorului] pentru informații despre utilizarea software-ului wiki.\n\n== Primii pași ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de setări de configurare]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Întrebări frecvente despre MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de e-mail pentru release-urile MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localizați MediaWiki în limba dumneavoastră]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Învățați cum să combateți spamul pe wikiul dumneavoastră]" } diff --git a/includes/installer/i18n/roa-tara.json b/includes/installer/i18n/roa-tara.json index 293ff0c2aa..6f6e003e1e 100644 --- a/includes/installer/i18n/roa-tara.json +++ b/includes/installer/i18n/roa-tara.json @@ -33,6 +33,7 @@ "config-restart": "Sìne, falle repartì", "config-env-php": "PHP $1 ha state installate.", "config-env-hhvm": "HHVM $1 ha state installate.", + "config-outdated-sqlite": "Iapre l'uecchjie: tu è SQLite $2, ca jè 'na versione troppe vecchie respette a quedda minime $1. SQLite non g'è disponibbele.", "config-db-type": "Tipe de database:", "config-db-host-oracle": "Database TNS:", "config-db-name-oracle": "Scheme d'u database:", diff --git a/includes/installer/i18n/ru.json b/includes/installer/i18n/ru.json index 8297dd80cf..4943e729fb 100644 --- a/includes/installer/i18n/ru.json +++ b/includes/installer/i18n/ru.json @@ -28,7 +28,8 @@ "Movses", "Vlad5250", "Athena Atterdag", - "Diralik" + "Diralik", + "Alexander Istomin" ] }, "config-desc": "Инсталлятор MediaWiki", @@ -74,11 +75,11 @@ "config-env-bad": "Была проведена проверка внешней среды.\nВы не можете установить MediaWiki.", "config-env-php": "Установленная версия PHP: $1.", "config-env-hhvm": "HHVM $1 установлена.", - "config-unicode-using-intl": "Будет использовано [https://pecl.php.net/intl расширение «intl» для PECL] для нормализации Юникода.", - "config-unicode-pure-php-warning": "'''Внимание!''': [https://pecl.php.net/intl расширение intl из PECL] недоступно для нормализации Юникода, будет использоваться медленная реализация на чистом PHP.\nЕсли ваш сайт работает под высокой нагрузкой, вам следует больше узнать о [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникода].", + "config-unicode-using-intl": "Будет использовано [https://php.net/manual/en/book.intl.php PHP intl расширение] для нормализации Юникода.", + "config-unicode-pure-php-warning": "'''Внимание!''': [https://php.net/manual/en/book.intl.php PHP intl расширение] недоступно для нормализации Юникода, будет использоваться медленная реализация на чистом PHP.\nЕсли ваш сайт работает под высокой нагрузкой, вам следует больше узнать о [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникода].", "config-unicode-update-warning": "'''Предупреждение''': установленная версия обёртки нормализации Юникода использует старую версию библиотеки [http://site.icu-project.org/ проекта ICU].\nВы должны [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations обновить версию], если хотите полноценно использовать Юникод.", "config-no-db": "Не удалось найти подходящие драйвера баз данных! Вам необходимо установить драйвера базы данных для PHP.\n{{PLURAL:$2|Поддерживается следующий тип|Поддерживаются следующие типы}} баз данных: $1.\n\nЕсли вы скомпилировали PHP сами, перенастройте его с включением клиента баз данных, например, с помощью ./configure --with-mysqli.\nЕсли вы установили PHP из пакетов Debian или Ubuntu, то вам также необходимо установить, например, пакет php-mysql.", - "config-outdated-sqlite": "'''Предупреждение''': у Вас установлен SQLite $1, версия которого ниже требуемой $2 . SQLite будет недоступен.", + "config-outdated-sqlite": "Предупреждение: у Вас установлен SQLite $2, версия которого ниже требуемой $1. SQLite будет недоступен.", "config-no-fts3": "'''Внимание''': SQLite собран без модуля [//sqlite.org/fts3.html FTS3] — поиск не будет работать для этой базы данных.", "config-pcre-old": "'''Фатальная ошибка:''' требуется PCRE версии $1 или более поздняя.\nВаш исполняемый файл PHP связан с PCRE версии $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Подробнее].", "config-pcre-no-utf8": "'''Фатальная ошибка'''. Модуль PCRE для PHP, похоже, собран без поддержки PCRE_UTF8.\nMediaWiki требует поддержки UTF-8 для корректной работы.", diff --git a/includes/installer/i18n/sl.json b/includes/installer/i18n/sl.json index 5471fdb48a..7543691b82 100644 --- a/includes/installer/i18n/sl.json +++ b/includes/installer/i18n/sl.json @@ -50,7 +50,7 @@ "config-env-bad": "Okolje je pregledano.\nNe morete namestiti MediaWiki.", "config-env-php": "Nameščen je PHP $1.", "config-env-hhvm": "HHVM $1 je nameščen.", - "config-unicode-using-intl": "Uporaba [https://pecl.php.net/intl razširitve PECL intl] za normalizacijo unikoda.", + "config-unicode-using-intl": "Uporaba [https://php.net/manual/en/book.intl.php PHP-razširitve intl] za normalizacijo unikoda.", "config-memory-raised": "PHP-jev memory_limit je $1, dvignjen na $2.", "config-apc": "[https://www.php.net/apc APC] je nameščen", "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] je nameščen", diff --git a/includes/installer/i18n/sr-ec.json b/includes/installer/i18n/sr-ec.json index 3ba7928dab..12b5620500 100644 --- a/includes/installer/i18n/sr-ec.json +++ b/includes/installer/i18n/sr-ec.json @@ -55,8 +55,8 @@ "config-env-bad": "Окружење је проверено.\nНе можете да инсталирате MediaWiki.", "config-env-php": "PHP $1 је инсталиран.", "config-env-hhvm": "HHVM $1 је инсталиран.", - "config-unicode-using-intl": "Користи се [https://pecl.php.net/intl додатак intl PECL] за нормализацију Уникода.", - "config-outdated-sqlite": "Упозорење: имате SQLite $1, који је нижи од најмање тражене верзије ($2). SQLite ће бити недоступан.", + "config-unicode-using-intl": "Користи се [https://php.net/manual/en/book.intl.php PHP intl додатак] за нормализацију Уникода.", + "config-outdated-sqlite": "Упозорење: имате SQLite $2, који је нижи од најмање тражене верзије $1. SQLite ће бити недоступан.", "config-no-fts3": "Упозорење: SQLite је компајлиран без [//sqlite.org/fts3.html FTS3 модула], функције претраге биће недоступне на овој бази података.", "config-pcre-old": "Неотклоњива грешка: Неопходан је PCRE $1 или новији.\nВаш бинарни PHP је повезан са PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Више информација].", "config-pcre-no-utf8": "Неотклоњива грешка: Изгледа да је PCRE модул PHP-а компајлиран без PCRE_UTF8 подршке.\nMediaWiki захтева UTF-8 подршку за исправно функционисање.", @@ -277,6 +277,7 @@ "config-install-done": "Честитамо!\nИнсталирали сте MediaWiki.\n\nИнсталациони програм је генерисао датотеку LocalSettings.php.\nОна садржи сву вашу конфигурацију.\n\nМораћете да је преузмете и ставите у базу ваше вики инсталације (исти директоријум као index.php). Преузимање би аутоматски требало почети.\n\nАко преузимање није понуђено, или ако га откажете, можете поново покренути преузимање тако што ћете кликнути на доленаведену везу:\n\n$3\n\nНапомена: Ако то одмах не урадите, ова генерисана конфигурациона датотека неће вам бити доступна касније ако изађете из инсталације без преузимања.\n\nКада је то учињено, можете да [$2 посетите свој вики].", "config-install-done-path": "Честитамо!\nИнсталирали сте MediaWiki.\n\nИнсталациони програм је генерисао датотеку LocalSettings.php.\nОна садржи сву вашу конфигурацију.\n\nМораћете да је преузмете и ставите у $4. Преузимање би аутоматски требало почети.\n\nАко преузимање није понуђено, или ако га откажете, можете поново покренути преузимање тако што ћете кликнути на доленаведену везу:\n\n$3\n\nНапомена: Ако то одмах не урадите, ова генерисана конфигурациона датотека неће вам бити доступна касније ако изађете из инсталације без преузимања.\n\nКада је то учињено, можете да [$2 посетите свој вики].", "config-install-success": "MediaWiki је успешно инсталиран. Сада можете посетити <$1$2> да бисте видели свој вики.\nАко имате питања, погледајте нашу листу често постављаних питања: или користите један од форума за подршку који су повезани на тој страници.", + "config-install-db-success": "База података је успешно подешена", "config-download-localsettings": "Преузми датотеку LocalSettings.php", "config-help": "помоћ", "config-help-tooltip": "кликните да бисте проширили", diff --git a/includes/installer/i18n/te.json b/includes/installer/i18n/te.json index 2fce89e2b5..1460fecef5 100644 --- a/includes/installer/i18n/te.json +++ b/includes/installer/i18n/te.json @@ -14,7 +14,7 @@ "config-localsettings-cli-upgrade": "ఓ LocalSettings.php ఫైలు కనబడింది.\nఈ స్థాపనను ఉన్నతీకరించడానికి, దాని బదులు update.php ను రన్ చెయ్యండి.", "config-localsettings-key": "ఉన్నతీకరణ కీ:", "config-localsettings-badkey": "మీరిచ్చిన కీ తప్పు.", - "config-upgrade-key-missing": "MediaWiki యొక్క ఒక స్థాపన కనబడింది.\nదాన్ని ఉన్నతీకరించడానికి, కింది లైనును LocalSettings.php లో అట్టడుగున ఉంచండి:\n\n$1", + "config-upgrade-key-missing": "ఇప్పటికే ఉన్న MediaWiki స్థాపన కనబడింది.\nదాన్ని నవీకరించడానికి, కింది లైనును LocalSettings.php లో అట్టడుగున ఉంచండి:\n\n$1", "config-localsettings-incomplete": "ఇప్పటి LocalSettings.php అసంపూర్తిగా ఉన్నట్లుగా కనబడుతోంది.\n$1 చరరాశిని సెట్ చెయ్యలేదు.\nఈ చరరాశిని సెట్ చేస్తూ LocalSettings.php ను మార్చి, \"{{int:Config-continue}}\" ను నొక్కండి.", "config-localsettings-connection-error": "LocalSettings.php లో ఇచ్చిన సెట్టింగులను వాడుతూ డేటాబేసుకు కనెక్టు కాబోతే, లోపం తలెత్తింది. ఈ సెట్టింగులను సరిచేసి మళ్ళీ ప్రయత్నించండి.\n\n$1", "config-session-error": "సెషన్ను ప్రారంభించబోతే లోపం జరిగింది: $1", @@ -128,7 +128,7 @@ "config-mssql-auth": "ఆథెంటికేషన్ రకం:", "config-mssql-sqlauth": "SQL Server ఆథెంటికేషన్", "config-mssql-windowsauth": "విండోస్ ఆథెంటికేషన్", - "config-site-name": "వికీ యొక్క పేరు:", + "config-site-name": "వికీ పేరు:", "config-site-name-help": "ఇది బ్రౌజరు టిటిలుబారు లోను, అనేక ఇతర చోట్లా కనిపిస్తుంది.", "config-site-name-blank": "ఓ సైటు పేరును ఇవ్వండి.", "config-project-namespace": "ప్రాజెక్టు పేరుబరి:", diff --git a/includes/installer/i18n/uk.json b/includes/installer/i18n/uk.json index 5a88debd47..321caefd37 100644 --- a/includes/installer/i18n/uk.json +++ b/includes/installer/i18n/uk.json @@ -65,7 +65,7 @@ "config-unicode-pure-php-warning": "'''Увага''': [https://pecl.php.net/intl міжнародне розширення PECL] не може провести нормалізацію Юнікоду.\nЯкщо ваш сайт має високий трафік, вам варто почитати про [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормалізацію Юнікоду].", "config-unicode-update-warning": "'''Увага''': Встановлена версія обгортки нормалізації Юнікоду використовує стару версію бібліотеки [http://site.icu-project.org/ проекту ICU].\nВи маєте [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations оновити версію], якщо плануєте повноцінно використовувати Юнікод.", "config-no-db": "Не вдалося знайти потрібний драйвер бази даних! Вам необхідно встановити драйвер бази даних для PHP. Підтримуються {{PLURAL:$2|такий тип|такі типи}} баз даних: $1.\n\nЯкщо ви скомпілювали PHP самостійно, переналаштуйте його з увімкненим клієнтом бази даних, наприклад за допомогою ./configure --with-mysqli.\n\nЯкщо установлено PHP з пакетів Debian або Ubuntu, тоді ви також повинні встановити, наприклад, пакунок php-mysql.", - "config-outdated-sqlite": "'''Увага''': у Вас встановлена версія SQLite $1, а це нижче, ніж мінімально необхідна версія $2. SQLite буде недоступним.", + "config-outdated-sqlite": "Увага: у Вас встановлена версія SQLite $2, а це нижче, ніж мінімально необхідна версія $1. SQLite буде недоступним.", "config-no-fts3": "'''Увага''': SQLite зібраний без [//sqlite.org/fts3.html модуля FTS3], функції пошуку не будуть працювати у цій системі.", "config-pcre-old": "'''Фатальна помилка:''' потрібно PCRE версії $1 або пізнішої.\nВаш виконуваний файл PHP пов'язаний з PCRE версії $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Подробиці].", "config-pcre-no-utf8": "'''Помилка''': PCRE-модуть PHP, вочевидь, було зібрано без підтримки PCRE_UTF8.\nMediaWiki вимагає підтримку UTF-8 для коректної роботи.", diff --git a/includes/installer/i18n/vi.json b/includes/installer/i18n/vi.json index 890f9966a6..4f083c60df 100644 --- a/includes/installer/i18n/vi.json +++ b/includes/installer/i18n/vi.json @@ -55,7 +55,7 @@ "config-env-php": "PHP $1 đã được cài đặt.", "config-env-hhvm": "HHVM $1 được cài đặt.", "config-unicode-using-intl": "Sẽ sử dụng [https://pecl.php.net/intl phần mở rộng PECL intl] để chuẩn hóa Unicode.", - "config-unicode-pure-php-warning": "Cảnh báo: [https://pecl.php.net/intl intl PECL extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn phải để ý qua một chút trên [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].", + "config-unicode-pure-php-warning": "Cảnh báo: [https://pecl.php.net/intl PECL intl extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn nên đọc qua [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations chuẩn hóa Unicode].", "config-unicode-update-warning": "Cảnh báo: Phiên bản cài đặt của gói Unicode chuẩn hóa sử dụng một phiên bản cũ của thư viện [http://site.icu-project.org/ the ICU project].\nBạn phải [https://www.mediawiki.org/wiki/Special:MyLanguage/nâng cấp Unicode_normalization_considerations] nếu bạn quan tâm đến việc sử dụng Unicode.", "config-no-db": "Không tìm thấy một trình điều khiển cơ sở dữ liệu phù hợp! Bạn cần phải cài một trình điều khiển cơ sở dữ liệu cho PHP.\n{{PLURAL:$2|Loại|Các loại}} cơ sở dữ liệu sau đây được hỗ trợ: $1.\n\nNếu bạn đã biên dịch PHP lấy, cấu hình lại nó mà kích hoạt một trình khách cơ sở dữ liệu, ví dụ bằng lệnh ./configure --with-mysqli.\nNếu bạn đã cài PHP từ một gói Debian hoặc Ubuntu, thì bạn cũng cần phải cài ví dụ gói php-mysql.", "config-outdated-sqlite": "Chú ý: Bạn có SQLite $1, phiên bản này thấp hơn phiên bản yêu câu tối thiểu $2. SQLite sẽ không có tác dụng.", @@ -69,9 +69,9 @@ "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] đã được cài đặt", "config-no-cache-apcu": "Cảnh báo: Không tìm thấy [https://www.php.net/apcu APCu] hoặc [https://www.iis.net/downloads/microsoft/wincache-extension WinCache].\nVùng nhớ đệm đối tượng không được kích hoạt.", "config-mod-security": "Cảnh báo: [https://modsecurity.org/ mod_security]/mod_security2 đã được kích hoạt trên máy chủ Web của bạn. Nhiều cấu hình phổ biến của phần mềm này sẽ gây vấn đề cho MediaWiki và những phần mềm khác cho phép người dùng đăng các nội dung tùy tiện.\nNếu có thể, bạn nên vô hiệu nó. Còn không, tra cứu [https://modsecurity.org/documentation/ tài liệu mod_security] hoặc liên hệ với nhà cung cấp hỗ trợ cho máy chủ nếu bạn gặp những lỗi ngẫu nhiên nào đó.", - "config-diff3-bad": "Không tìm thấy GNU diff3.", + "config-diff3-bad": "Không tìm thấy tiện ích so sánh văn bản GNU diff3. Bạn có thể bỏ qua vấn đề này bây giờ, nhưng các mâu thuẫn sửa đổi có thể xảy ra thường xuyên hơn.", "config-git": "Đã tìm thấy phần mềm điều khiển phiên bản Git: $1.", - "config-git-bad": "Không tìm thấy phần mềm điều khiển phiên bản Git.", + "config-git-bad": "Không tìm thấy phần mềm điều khiển phiên bản Git. Bạn có thể bỏ qua vấn đề này bây giờ. Lưu ý rằng Đặc biệt:Phiên bản sẽ không hiển thị các số băm chuyển giao.", "config-imagemagick": "Đã tìm thấy ImageMagick: $1.\nChức năng thu nhỏ hình ảnh sẽ được kích hoạt nếu bạn cho phép tải lên.", "config-gd": "Đã tìm thấy thư viện đồ họa GD đi kèm.\nChức năng thu nhỏ hình ảnh sẽ được kích hoạt nếu bạn cho phép tải lên.", "config-no-scaling": "Không thể tìm thấy thư viện GD hoặc ImageMagic. Chức năng thu nhỏ hình ảnh sẽ bị vô hiệu.", @@ -86,11 +86,11 @@ "config-using-32bit": "Cảnh báo: Máy của bạn hình như sử dụng các số nguyên 32 bit. Chế độ này [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit không được khuyên khích].", "config-db-type": "Kiểu cơ sở dữ liệu:", "config-db-host": "Máy chủ của cơ sở dữ liệu:", - "config-db-host-help": "Nếu máy chủ cơ sở dữ liệu của bạn nằm trên máy chủ khác, hãy điền tên hoặc địa chỉ IP của máy chủ vào đây.\n\nNếu bạn đang dùng Web hosting chia sẻ, tài liệu của nhà cung cấp hosting của bạn sẽ có tên chính xác của máy chủ.\n\nNếu bạn đang cài đặt trên một máy chủ Windows và sử dụng MySQL, việc dùng “localhost” có thể không hợp với tên máy chủ. Nếu bị như vậy, hãy thử “127.0.0.1” tức địa chỉ IP địa phương.\n\nNếu bạn đang dùng PostgreSQL, hãy để trống mục này để kết nối với một ổ cắm Unix.", + "config-db-host-help": "Nếu máy chủ cơ sở dữ liệu của bạn nằm trên máy chủ khác, hãy điền tên hoặc địa chỉ IP của máy chủ vào đây.\n\nNếu bạn đang dùng Web hosting chia sẻ, tài liệu của nhà cung cấp hosting của bạn sẽ có tên chính xác của máy chủ.\n\nNếu bạn đang sử dụng MySQL, việc dùng “localhost” có thể không hợp với tên máy chủ. Nếu bị như vậy, hãy thử “127.0.0.1” tức địa chỉ IP địa phương.\n\nNếu bạn đang dùng PostgreSQL, hãy để trống mục này để kết nối với một ổ cắm Unix.", "config-db-host-oracle": "TNS cơ sở dữ liệu:", "config-db-host-oracle-help": "Nhập một [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Tên Kết nối Địa phương] hợp lệ; một tập tin tnsnames.ora phải được hiển thị đối với cài đặt này.
Nếu bạn đang sử dụng các thư viện trình khách 10g trở lên, bạn cũng có thể sử dụng phương pháp đặt tên [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].", "config-db-wiki-settings": "Dữ liệu để nhận ra wiki này", - "config-db-name": "Tên cơ sở dữ liệu:", + "config-db-name": "Tên cơ sở dữ liệu (không có dấu gạch ngang):", "config-db-name-help": "Chọn một tên để chỉ thị wiki của bạn.\nKhông nên đưa dấu cách vào tên này.\n\nNếu bạn đang sử dụng Web hosting chia sẻ, nhà cung cấp hosting của bạn hoặc là sẽ cung cấp cho bạn một tên cơ sở dữ liệu cụ thể để sử dụng hoặc là sẽ cho phép bạn tạo ra các cơ sở dữ liệu thông qua một bảng điều khiển.", "config-db-name-oracle": "Giản đồ cơ sở dữ liệu:", "config-db-account-oracle-warn": "Có ba trường hợp được hỗ trợ để cài đặt Oracle làm cơ sở dữ liệu phía sau:\n\nNếu bạn muốn tạo tài khoản cơ sở dữ liệu trong quá trình cài đặt, xin vui lòng cung cấp một tài khoản với vai trò SYSDBA là tài khoản cơ sở dữ liệu để cài đặt và xác định định danh mong muốn cho tài khoản truy cập Web, nếu không bạn có thể tạo tài khoản truy cập Web thủ công và chỉ cung cấp tài khoản đó (nếu nó có các quyền yêu cầu để tạo ra các đối tượng giản đồ) hoặc cung cấp hai tài khoản riêng, một có quyền tạo ra và một bị hạn chế có quyền truy cập Web.\n\nMột kịch bản để tạo một tài khoản với quyền yêu cầu có sẵn trong thư mục cài đặt “maintenance/oracle/”. Hãy nhớ rằng việc sử dụng một tài khoản bị hạn chế sẽ vô hiệu hóa tất cả các khả năng bảo trì với tài khoản mặc định.", @@ -103,11 +103,11 @@ "config-db-account-lock": "Sử dụng cùng tên người dùng và mật khẩu trong quá trình hoạt động bình thường", "config-db-wiki-account": "Tài khoản người dùng để hoạt động bình thường", "config-db-wiki-help": "Nhập tên người dùng và mật khẩu sẽ được sử dụng để kết nối với cơ sở dữ liệu trong quá trình hoạt động bình thường của wiki.\nNếu tài khoản không tồn tại và tài khoản cài đặt có đủ quyền hạn, tài khoản người dùng này sẽ được tạo ra với những đặc quyền tối thiểu cần thiết để vận hành wiki.", - "config-db-prefix": "Tiền tố bảng cơ sở dữ liệu:", + "config-db-prefix": "Tiền tố bảng cơ sở dữ liệu (không có dấu gạch ngang):", "config-db-prefix-help": "Nếu bạn cần phải chia sẻ một cơ sở dữ liệu chung với nhiều wiki, hay giữa MediaWiki và một ứng dụng Web, bạn có thể quyết định thêm một tiền tố cho tất cả các tên bảng để tránh xung đột.\nKhông sử dụng dấu cách.\n\nTrường này thường được bỏ trống.", "config-mysql-old": "Cần MySQL $1 trở lên; bạn có $2.", "config-db-port": "Cổng cơ sở dữ liệu:", - "config-db-schema": "Giản đồ cho MediaWiki:", + "config-db-schema": "Giản đồ cho MediaWiki (không có dấu gạch ngang):", "config-db-schema-help": "Giản đồ này thường làm việc tốt.\nChỉ thay đổi nó nếu bạn biết cần phải làm như vậy.", "config-pg-test-error": "Không thể kết nối với cơ sở dữ liệu '''$1''': $2", "config-sqlite-dir": "Thư mục dữ liệu SQLite:", @@ -134,7 +134,7 @@ "config-invalid-db-server-oracle": "Cơ sở dữ liệu TNS không hợp lệ “$1”.\nHoặc sử dụng “TNS Name” hoặc một chuỗi “Easy Connect” ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Phương pháp đặt tên Oracle]).", "config-invalid-db-name": "Tên cơ sở dữ liệu không hợp lệ “$1”.\nChỉ sử dụng các chữ cái ASCII (a–z, A–Z), số (0–9), dấu gạch dưới (_) và dấu gạch ngang (-).", "config-invalid-db-prefix": "Tiền tố cơ sở dữ liệu không hợp lệ “$1”.\nChỉ sử dụng các chữ cái ASCII (a–z, A–Z), số (0–9), dấu gạch dưới (_) và dấu gạch ngang (-).", - "config-connection-error": "$1.\n\nKiểm tra máy chủ, tên người dùng, và mật khẩu và thử lại lần nữa.", + "config-connection-error": "$1.\n\nKiểm tra máy chủ, tên người dùng, và mật khẩu và thử lại lần nữa. Nếu sử dụng “localhost” làm máy chủ cơ sở dữ liệu, hãy thử sử dụng “127.0.0.1” thay thế (hoặc ngược lại).", "config-invalid-schema": "Giản đồ “$1” không hợp lệ cho MediaWiki.\nHãy chỉ sử dụng các chữ cái ASCII (a–z, A–Z), chữ số (0–9), và dấu gạch dưới (_).", "config-db-sys-create-oracle": "Trình cài đặt chỉ hỗ trợ sử dụng một tài khoản SYSDBA để tạo một tài khoản mới.", "config-db-sys-user-exists-oracle": "Tài khoản người dùng “$1” đã tồn tại. SYSDBA chỉ có thể được sử dụng để tạo một tài khoản mới!", @@ -150,6 +150,7 @@ "config-sqlite-cant-create-db": "Không thể tạo ra tập tin cơ sở dữ liệu $1.", "config-sqlite-fts3-downgrade": "PHP thiếu sự hỗ trợ cho FTS3; đang giáng cấp các bảng", "config-can-upgrade": "Cơ sở dữ liệu này có bảng MediaWiki.\nĐể nâng cấp các bảng đến MediaWiki $1, bấm Tiếp tục.", + "config-upgrade-error": "Xuất hiện lỗi khi nâng cấp các bảng MediaWiki trong cơ sở dữ liệu của bạn.\n\nĐể tìm hiểu thêm, xem lại nhật trình bên trên. Để thử lại, bấm Tiếp tục.", "config-upgrade-done": "Nâng cấp đã hoàn thành.\n\nBạn có thể [$1 bắt đầu sử dụng wiki của bạn] ngay bây giờ.\n\nNếu bạn muốn tạo lại tập tin LocalSettings.php của bạn, bấm nút bên dưới.\nĐiều này không được khuyến khích, trừ khi bạn đang gặp vấn đề với wiki của bạn.", "config-upgrade-done-no-regenerate": "Nâng cấp đã hoàn thành.\n\nBạn có thể [$1 bắt đầu sử dụng wiki của bạn] ngay bây giờ.", "config-regenerate": "Tạo lại LocalSettings.php →", @@ -274,7 +275,7 @@ "config-install-schema": "Đang tạo giản đồ", "config-install-pg-schema-not-exist": "Lược đồ PostgreSQL không tồn tại.", "config-install-pg-schema-failed": "Thất bại khi tạo các bảng.\nHãy chắc chắn rằng người dùng “$1” có thể ghi vào giản đồ “$2”.", - "config-install-pg-commit": "Đang gửi các thay đổi", + "config-install-pg-commit": "Đang chuyển giao các thay đổi", "config-install-pg-plpgsql": "Tìm ngôn ngữ PL/pgSQL", "config-pg-no-plpgsql": "Bạn cần phải cài đặt ngôn ngữ PL/pgSQL vào cơ sở dữ liệu $1", "config-pg-no-create-privs": "Tài khoản bạn xác định để cài đặt không đủ quyền hạn để tạo một tài khoản.", @@ -305,6 +306,7 @@ "config-install-done": "Xin chúc mừng!\nBạn đã cài đặt MediaWiki.\n\nBộ cài đặt đã tạo ra một tập tin LocalSettings.php.\nTập tin này chứa tất cả các cấu hình của bạn.\n\nBạn sẽ cần phải tải nó về và đặt nó trong thư mục cài đặt wiki của bạn (cùng thư mục với index.php). Việc tải về có lẽ sẽ được khởi động tự động.\n\nNếu bản tải về không được cung cấp, hoặc nếu bạn hủy bỏ nó, bạn có thể khởi động lại tải về bằng cách nhấn vào liên kết dưới đây:\n\n$3\n\nLưu ý: Nếu bạn không làm điều này ngay bây giờ, điều này sẽ tạo ra tập tin cấu hình sẽ không có giá trị cho bạn sau này nếu bạn thoát khỏi trình cài đặt mà không tải nó về.\n\nKhi đã việc tải về đã hoàn thành, bạn có thể [$2 truy cập trang wiki của bạn].", "config-install-done-path": "Xin chúc mừng!\nBạn đã cài đặt MediaWiki.\n\nBộ cài đặt đã tạo ra một tập tin LocalSettings.php.\nTập tin này chứa tất cả các cấu hình của bạn.\n\nBạn sẽ cần phải tải nó về và đặt nó tại $4. Việc tải về có lẽ sẽ được khởi động tự động.\n\nNếu bản tải về không được cung cấp, hoặc nếu bạn hủy bỏ nó, bạn có thể khởi động lại tải về bằng cách nhấn vào liên kết dưới đây:\n\n$3\n\nLưu ý: Nếu bạn không làm điều này ngay bây giờ, điều này sẽ tạo ra tập tin cấu hình sẽ không có giá trị cho bạn sau này nếu bạn thoát khỏi trình cài đặt mà không tải nó về.\n\nKhi đã việc tải về đã hoàn thành, bạn có thể [$2 truy cập trang wiki của bạn].", "config-install-success": "MediaWiki đã được cài đặt thành công. Bây giờ bạn có thể mở <$1$2> để xem wiki của bạn.\nNếu bạn có thắc mắc, hãy đọc các câu thường hỏi:\n hoặc ghé vào một diễn đàn hỗ trợ được liệt kê tại trang đó.", + "config-install-db-success": "Cơ sở dữ liệu được thiết lập thành công", "config-download-localsettings": "Tải về LocalSettings.php", "config-help": "Trợ giúp", "config-help-tooltip": "nhấn chuột để mở rộng", diff --git a/includes/interwiki/InterwikiLookup.php b/includes/interwiki/InterwikiLookup.php index 697e39d540..c9a67d5f8c 100644 --- a/includes/interwiki/InterwikiLookup.php +++ b/includes/interwiki/InterwikiLookup.php @@ -1,4 +1,5 @@ domain, $jobs ) ); } - /** - * Push all jobs buffered via lazyPush() into their respective queues - * - * @return void - * @since 1.26 - * @deprecated Since 1.33 Not needed anymore - */ - public static function pushLazyJobs() { - wfDeprecated( __METHOD__, '1.33' ); - } - /** * Pop a job off one of the job queues * @@ -286,10 +271,10 @@ class JobQueueGroup { /** * Acknowledge that a job was completed * - * @param Job $job + * @param RunnableJob $job * @return void */ - public function ack( Job $job ) { + public function ack( RunnableJob $job ) { $this->get( $job->getType() )->ack( $job ); } @@ -297,10 +282,10 @@ class JobQueueGroup { * Register the "root job" of a given job into the queue for de-duplication. * This should only be called right *after* all the new jobs have been inserted. * - * @param Job $job + * @param RunnableJob $job * @return bool */ - public function deduplicateRootJob( Job $job ) { + public function deduplicateRootJob( RunnableJob $job ) { return $this->get( $job->getType() )->deduplicateRootJob( $job ); } diff --git a/includes/jobqueue/JobRunner.php b/includes/jobqueue/JobRunner.php index 676659f384..21d8c7e8af 100644 --- a/includes/jobqueue/JobRunner.php +++ b/includes/jobqueue/JobRunner.php @@ -23,7 +23,7 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Logger\LoggerFactory; -use Liuggio\StatsdClient\Factory\StatsdDataFactory; +use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Wikimedia\ScopedCallback; @@ -265,13 +265,13 @@ class JobRunner implements LoggerAwareInterface { } /** - * @param Job $job + * @param RunnableJob $job * @param LBFactory $lbFactory - * @param StatsdDataFactory $stats + * @param StatsdDataFactoryInterface $stats * @param float $popTime * @return array Map of status/error/timeMs */ - private function executeJob( Job $job, LBFactory $lbFactory, $stats, $popTime ) { + private function executeJob( RunnableJob $job, LBFactory $lbFactory, $stats, $popTime ) { $jType = $job->getType(); $msg = $job->toString() . " STARTING"; $this->logger->debug( $msg, [ @@ -367,11 +367,11 @@ class JobRunner implements LoggerAwareInterface { } /** - * @param Job $job + * @param RunnableJob $job * @return int Seconds for this runner to avoid doing more jobs of this type * @see $wgJobBackoffThrottling */ - private function getBackoffTimeToWait( Job $job ) { + private function getBackoffTimeToWait( RunnableJob $job ) { $throttling = $this->config->get( 'JobBackoffThrottling' ); if ( !isset( $throttling[$job->getType()] ) || $job instanceof DuplicateJob ) { @@ -526,16 +526,16 @@ class JobRunner implements LoggerAwareInterface { * $wgJobSerialCommitThreshold for more. * * @param LBFactory $lbFactory - * @param Job $job + * @param RunnableJob $job * @param string $fnameTrxOwner * @throws DBError */ - private function commitMasterChanges( LBFactory $lbFactory, Job $job, $fnameTrxOwner ) { + private function commitMasterChanges( LBFactory $lbFactory, RunnableJob $job, $fnameTrxOwner ) { $syncThreshold = $this->config->get( 'JobSerialCommitThreshold' ); $time = false; $lb = $lbFactory->getMainLB(); - if ( $syncThreshold !== false && $lb->getServerCount() > 1 ) { + if ( $syncThreshold !== false && $lb->hasStreamingReplicaServers() ) { // Generally, there is one master connection to the local DB $dbwSerial = $lb->getAnyOpenConnection( $lb->getWriterIndex() ); // We need natively blocking fast locks diff --git a/includes/jobqueue/JobSpecification.php b/includes/jobqueue/JobSpecification.php index 80a46d04ba..19ff9677ea 100644 --- a/includes/jobqueue/JobSpecification.php +++ b/includes/jobqueue/JobSpecification.php @@ -27,8 +27,8 @@ * @code * $job = new JobSpecification( * 'null', - * array( 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ), - * array( 'removeDuplicates' => 1 ) + * [ 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ], + * [ 'removeDuplicates' => 1 ] * ); * JobQueueGroup::singleton()->push( $job ) * @endcode diff --git a/includes/jobqueue/jobs/ActivityUpdateJob.php b/includes/jobqueue/jobs/ActivityUpdateJob.php index 9b085108be..4de72a9b12 100644 --- a/includes/jobqueue/jobs/ActivityUpdateJob.php +++ b/includes/jobqueue/jobs/ActivityUpdateJob.php @@ -59,9 +59,7 @@ class ActivityUpdateJob extends Job { } protected function updateWatchlistNotification() { - $casTimestamp = ( $this->params['notifTime'] !== null ) - ? $this->params['notifTime'] - : $this->params['curTime']; + $casTimestamp = $this->params['notifTime'] ?? $this->params['curTime']; $dbw = wfGetDB( DB_MASTER ); $dbw->update( 'watchlist', diff --git a/includes/jobqueue/jobs/CategoryMembershipChangeJob.php b/includes/jobqueue/jobs/CategoryMembershipChangeJob.php index 3aedc38f5f..be76fc63b3 100644 --- a/includes/jobqueue/jobs/CategoryMembershipChangeJob.php +++ b/includes/jobqueue/jobs/CategoryMembershipChangeJob.php @@ -91,9 +91,9 @@ class CategoryMembershipChangeJob extends Job { return false; // deleted? } - // Cut down on the time spent in safeWaitForMasterPos() in the critical section + // Cut down on the time spent in waitForMasterPos() in the critical section $dbr = $lb->getConnection( DB_REPLICA, [ 'recentchanges' ] ); - if ( !$lb->safeWaitForMasterPos( $dbr ) ) { + if ( !$lb->waitForMasterPos( $dbr ) ) { $this->setLastError( "Timed out while pre-waiting for replica DB to catch up" ); return false; } @@ -107,7 +107,7 @@ class CategoryMembershipChangeJob extends Job { } // Wait till replica DB is caught up so that jobs for this page see each others' changes - if ( !$lb->safeWaitForMasterPos( $dbr ) ) { + if ( !$lb->waitForMasterPos( $dbr ) ) { $this->setLastError( "Timed out while waiting for replica DB to catch up" ); return false; } diff --git a/includes/jobqueue/jobs/ClearUserWatchlistJob.php b/includes/jobqueue/jobs/ClearUserWatchlistJob.php index 0cb1a52d69..1793053a45 100644 --- a/includes/jobqueue/jobs/ClearUserWatchlistJob.php +++ b/includes/jobqueue/jobs/ClearUserWatchlistJob.php @@ -44,7 +44,7 @@ class ClearUserWatchlistJob extends Job implements GenericParameterJob { $dbr = $loadBalancer->getConnection( DB_REPLICA, [ 'watchlist' ] ); // Wait before lock to try to reduce time waiting in the lock. - if ( !$loadBalancer->safeWaitForMasterPos( $dbr ) ) { + if ( !$loadBalancer->waitForMasterPos( $dbr ) ) { $this->setLastError( 'Timed out waiting for replica to catch up before lock' ); return false; } @@ -57,7 +57,7 @@ class ClearUserWatchlistJob extends Job implements GenericParameterJob { return false; } - if ( !$loadBalancer->safeWaitForMasterPos( $dbr ) ) { + if ( !$loadBalancer->waitForMasterPos( $dbr ) ) { $this->setLastError( 'Timed out waiting for replica to catch up within lock' ); return false; } diff --git a/includes/jobqueue/jobs/DoubleRedirectJob.php b/includes/jobqueue/jobs/DoubleRedirectJob.php index 1bcbd30c97..2c2cf89efc 100644 --- a/includes/jobqueue/jobs/DoubleRedirectJob.php +++ b/includes/jobqueue/jobs/DoubleRedirectJob.php @@ -29,10 +29,6 @@ use MediaWiki\MediaWikiServices; * @ingroup JobQueue */ class DoubleRedirectJob extends Job { - /** @var string Reason for the change, 'maintenance' or 'move'. Suffix fo - * message key 'double-redirect-fixed-'. - */ - private $reason; /** @var Title The title which has changed, redirects pointing to this * title are fixed @@ -44,11 +40,15 @@ class DoubleRedirectJob extends Job { /** * @param Title $title - * @param array $params + * @param array $params Expected to contain these elements: + * - 'redirTitle' => string The title that changed and should be fixed. + * - 'reason' => string Reason for the change, can be "move" or "maintenance". Used as a suffix + * for the message keys "double-redirect-fixed-move" and + * "double-redirect-fixed-maintenance". + * ] */ function __construct( Title $title, array $params ) { parent::__construct( 'fixDoubleRedirect', $title, $params ); - $this->reason = $params['reason']; $this->redirTitle = Title::newFromText( $params['redirTitle'] ); } @@ -166,7 +166,7 @@ class DoubleRedirectJob extends Job { $article = WikiPage::factory( $this->title ); // Messages: double-redirect-fixed-move, double-redirect-fixed-maintenance - $reason = wfMessage( 'double-redirect-fixed-' . $this->reason, + $reason = wfMessage( 'double-redirect-fixed-' . $this->params['reason'], $this->redirTitle->getPrefixedText(), $newTitle->getPrefixedText() )->inContentLanguage()->text(); $flags = EDIT_UPDATE | EDIT_SUPPRESS_RC | EDIT_INTERNAL; diff --git a/includes/jobqueue/jobs/RefreshLinksJob.php b/includes/jobqueue/jobs/RefreshLinksJob.php index b1c805b4a4..89ecb0ee92 100644 --- a/includes/jobqueue/jobs/RefreshLinksJob.php +++ b/includes/jobqueue/jobs/RefreshLinksJob.php @@ -279,9 +279,7 @@ class RefreshLinksJob extends Job { 'recursive' => !empty( $this->params['useRecursiveLinksUpdate'] ), // Carry over cause so the update can do extra logging 'causeAction' => $this->params['causeAction'], - 'causeAgent' => $this->params['causeAgent'], - 'defer' => false, - 'transactionTicket' => $ticket, + 'causeAgent' => $this->params['causeAgent'] ]; if ( !empty( $this->params['triggeringUser'] ) ) { $userInfo = $this->params['triggeringUser']; diff --git a/includes/jobqueue/jobs/ThumbnailRenderJob.php b/includes/jobqueue/jobs/ThumbnailRenderJob.php index eb8b1a2780..85e3af9d2d 100644 --- a/includes/jobqueue/jobs/ThumbnailRenderJob.php +++ b/includes/jobqueue/jobs/ThumbnailRenderJob.php @@ -21,6 +21,8 @@ * @ingroup JobQueue */ +use MediaWiki\MediaWikiServices; + /** * Job for asynchronous rendering of thumbnails. * @@ -36,7 +38,8 @@ class ThumbnailRenderJob extends Job { $transformParams = $this->params['transformParams']; - $file = wfLocalFile( $this->title ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo() + ->newFile( $this->title ); $file->load( File::READ_LATEST ); if ( $file && $file->exists() ) { diff --git a/includes/language/LanguageCode.php b/includes/language/LanguageCode.php new file mode 100644 index 0000000000..7d954d3803 --- /dev/null +++ b/includes/language/LanguageCode.php @@ -0,0 +1,204 @@ + 'gsw', // T25215 + 'bat-smg' => 'sgs', // T27522 + 'be-x-old' => 'be-tarask', // T11823 + 'fiu-vro' => 'vro', // T31186 + 'roa-rup' => 'rup', // T17988 + 'zh-classical' => 'lzh', // T30443 + 'zh-min-nan' => 'nan', // T30442 + 'zh-yue' => 'yue', // T30441 + ]; + + /** + * Mapping of non-standard language codes used in MediaWiki to + * standardized BCP 47 codes. These are not deprecated (yet?): + * IANA may eventually recognize the subtag, in which case the `-x-` + * infix could be removed, or else we could rename the code in + * MediaWiki, in which case they'd move up to the above mapping + * of deprecated codes. + * + * As a rule, we preserve all distinctions made by MediaWiki + * internally. For example, `de-formal` becomes `de-x-formal` + * instead of just `de` because MediaWiki distinguishes `de-formal` + * from `de` (for example, for interface translations). Similarly, + * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it + * "typically does not add information", but in our case MediaWiki + * LanguageConverter distinguishes `kk` (render content in a mix of + * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly + * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST, + * `kk-Cyrl` is a valid code, although some validators may emit + * a warning note. + * + * @var array Mapping from nonstandard MediaWiki-internal codes to + * BCP 47 codes + * + * @since 1.32 + * @see https://meta.wikimedia.org/wiki/Special_language_codes + * @see https://phabricator.wikimedia.org/T125073 + */ + private static $nonstandardLanguageCodeMapping = [ + // All codes returned by Language::fetchLanguageNames() validated + // against IANA registry at + // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + // with help of validator at + // http://schneegans.de/lv/ + 'cbk-zam' => 'cbk', // T124657 + 'de-formal' => 'de-x-formal', + 'eml' => 'egl', // T36217 + 'en-rtl' => 'en-x-rtl', + 'es-formal' => 'es-x-formal', + 'hu-formal' => 'hu-x-formal', + 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073 + 'mo' => 'ro-Cyrl-MD', // T125073 + 'nrm' => 'nrf', // [[en:Norman_language]] T25216 + 'nl-informal' => 'nl-x-informal', + 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]] + 'simple' => 'en-simple', + 'sr-ec' => 'sr-Cyrl', // T117845 + 'sr-el' => 'sr-Latn', // T117845 + + // Although these next codes aren't *wrong* per se, including + // both the script and the country code helps compatibility with + // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`, + // without a country code, and those should be left alone. + // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.) + 'zh-cn' => 'zh-Hans-CN', + 'zh-sg' => 'zh-Hans-SG', + 'zh-my' => 'zh-Hans-MY', + 'zh-tw' => 'zh-Hant-TW', + 'zh-hk' => 'zh-Hant-HK', + 'zh-mo' => 'zh-Hant-MO', + ]; + + /** + * Returns a mapping of deprecated language codes that were used in previous + * versions of MediaWiki to up-to-date, current language codes. + * + * This array is merged into $wgDummyLanguageCodes in Setup.php, along with + * the fake language codes 'qqq' and 'qqx', which are used internally by + * MediaWiki's localisation system. + * + * @return string[] + * + * @since 1.29 + */ + public static function getDeprecatedCodeMapping() { + return self::$deprecatedLanguageCodeMapping; + } + + /** + * Returns a mapping of non-standard language codes used by + * (current and previous version of) MediaWiki, mapped to standard + * BCP 47 names. + * + * This array is exported to JavaScript to ensure + * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47(). + * + * @return string[] + * + * @since 1.32 + */ + public static function getNonstandardLanguageCodeMapping() { + $result = []; + foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + return $result; + } + + /** + * Replace deprecated language codes that were used in previous + * versions of MediaWiki to up-to-date, current language codes. + * Other values will returned unchanged. + * + * @param string $code Old language code + * @return string New language code + * + * @since 1.30 + */ + public static function replaceDeprecatedCodes( $code ) { + return self::$deprecatedLanguageCodeMapping[$code] ?? $code; + } + + /** + * Get the normalised IETF language tag + * See unit test for examples. + * See mediawiki.language.bcp47 for the JavaScript implementation. + * + * @param string $code The language code. + * @return string A language code complying with BCP 47 standards. + * + * @since 1.31 + */ + public static function bcp47( $code ) { + $code = self::replaceDeprecatedCodes( strtolower( $code ) ); + if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) { + $code = self::$nonstandardLanguageCodeMapping[$code]; + } + $codeSegment = explode( '-', $code ); + $codeBCP = []; + foreach ( $codeSegment as $segNo => $seg ) { + // when previous segment is x, it is a private segment and should be lc + if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) { + $codeBCP[$segNo] = strtolower( $seg ); + // ISO 3166 country code + } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) { + $codeBCP[$segNo] = strtoupper( $seg ); + // ISO 15924 script code + } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) { + $codeBCP[$segNo] = ucfirst( strtolower( $seg ) ); + // Use lowercase for other cases + } else { + $codeBCP[$segNo] = strtolower( $seg ); + } + } + $langCode = implode( '-', $codeBCP ); + return $langCode; + } +} diff --git a/includes/language/Message.php b/includes/language/Message.php new file mode 100644 index 0000000000..12007faf87 --- /dev/null +++ b/includes/language/Message.php @@ -0,0 +1,1398 @@ +text() + * ); + * @endcode + * + * A Message instance can be passed parameters after it has been constructed, + * use the params() method to do so: + * + * @code + * wfMessage( 'welcome-to' ) + * ->params( $wgSitename ) + * ->text(); + * @endcode + * + * {{GRAMMAR}} and friends work correctly: + * + * @code + * wfMessage( 'are-friends', + * $user, $friend + * ); + * wfMessage( 'bad-message' ) + * ->rawParams( '' ) + * ->escaped(); + * @endcode + * + * @section message_language Changing language: + * + * Messages can be requested in a different language or in whatever current + * content language is being used. The methods are: + * - Message->inContentLanguage() + * - Message->inLanguage() + * + * Sometimes the message text ends up in the database, so content language is + * needed: + * + * @code + * wfMessage( 'file-log', + * $user, $filename + * )->inContentLanguage()->text(); + * @endcode + * + * Checking whether a message exists: + * + * @code + * wfMessage( 'mysterious-message' )->exists() + * // returns a boolean whether the 'mysterious-message' key exist. + * @endcode + * + * If you want to use a different language: + * + * @code + * $userLanguage = $user->getOption( 'language' ); + * wfMessage( 'email-header' ) + * ->inLanguage( $userLanguage ) + * ->plain(); + * @endcode + * + * @note You can parse the text only in the content or interface languages + * + * @section message_compare_old Comparison with old wfMsg* functions: + * + * Use full parsing: + * + * @code + * // old style: + * wfMsgExt( 'key', [ 'parseinline' ], 'apple' ); + * // new style: + * wfMessage( 'key', 'apple' )->parse(); + * @endcode + * + * Parseinline is used because it is more useful when pre-building HTML. + * In normal use it is better to use OutputPage::(add|wrap)WikiMsg. + * + * Places where HTML cannot be used. {{-transformation is done. + * @code + * // old style: + * wfMsgExt( 'key', [ 'parsemag' ], 'apple', 'pear' ); + * // new style: + * wfMessage( 'key', 'apple', 'pear' )->text(); + * @endcode + * + * Shortcut for escaping the message too, similar to wfMsgHTML(), but + * parameters are not replaced after escaping by default. + * @code + * $escaped = wfMessage( 'key' ) + * ->rawParams( 'apple' ) + * ->escaped(); + * @endcode + * + * @section message_appendix Appendix: + * + * @todo + * - test, can we have tests? + * - this documentation needs to be extended + * + * @see https://www.mediawiki.org/wiki/WfMessage() + * @see https://www.mediawiki.org/wiki/New_messages_API + * @see https://www.mediawiki.org/wiki/Localisation + * + * @since 1.17 + */ +class Message implements MessageSpecifier, Serializable { + /** Use message text as-is */ + const FORMAT_PLAIN = 'plain'; + /** Use normal wikitext -> HTML parsing (the result will be wrapped in a block-level HTML tag) */ + const FORMAT_BLOCK_PARSE = 'block-parse'; + /** Use normal wikitext -> HTML parsing but strip the block-level wrapper */ + const FORMAT_PARSE = 'parse'; + /** Transform {{..}} constructs but don't transform to HTML */ + const FORMAT_TEXT = 'text'; + /** Transform {{..}} constructs, HTML-escape the result */ + const FORMAT_ESCAPED = 'escaped'; + + /** + * Mapping from Message::listParam() types to Language methods. + * @var array + */ + protected static $listTypeMap = [ + 'comma' => 'commaList', + 'semicolon' => 'semicolonList', + 'pipe' => 'pipeList', + 'text' => 'listToText', + ]; + + /** + * In which language to get this message. True, which is the default, + * means the current user language, false content language. + * + * @var bool + */ + protected $interface = true; + + /** + * In which language to get this message. Overrides the $interface setting. + * + * @var Language|bool Explicit language object, or false for user language + */ + protected $language = false; + + /** + * @var string The message key. If $keysToTry has more than one element, + * this may change to one of the keys to try when fetching the message text. + */ + protected $key; + + /** + * @var string[] List of keys to try when fetching the message. + */ + protected $keysToTry; + + /** + * @var array List of parameters which will be substituted into the message. + */ + protected $parameters = []; + + /** + * @var string + * @deprecated + */ + protected $format = 'parse'; + + /** + * @var bool Whether database can be used. + */ + protected $useDatabase = true; + + /** + * @var Title Title object to use as context. + */ + protected $title = null; + + /** + * @var Content Content object representing the message. + */ + protected $content = null; + + /** + * @var string + */ + protected $message; + + /** + * @since 1.17 + * @param string|string[]|MessageSpecifier $key Message key, or array of + * message keys to try and use the first non-empty message for, or a + * MessageSpecifier to copy from. + * @param array $params Message parameters. + * @param Language|null $language [optional] Language to use (defaults to current user language). + * @throws InvalidArgumentException + */ + public function __construct( $key, $params = [], Language $language = null ) { + if ( $key instanceof MessageSpecifier ) { + if ( $params ) { + throw new InvalidArgumentException( + '$params must be empty if $key is a MessageSpecifier' + ); + } + $params = $key->getParams(); + $key = $key->getKey(); + } + + if ( !is_string( $key ) && !is_array( $key ) ) { + throw new InvalidArgumentException( '$key must be a string or an array' ); + } + + $this->keysToTry = (array)$key; + + if ( empty( $this->keysToTry ) ) { + throw new InvalidArgumentException( '$key must not be an empty list' ); + } + + $this->key = reset( $this->keysToTry ); + + $this->parameters = array_values( $params ); + // User language is only resolved in getLanguage(). This helps preserve the + // semantic intent of "user language" across serialize() and unserialize(). + $this->language = $language ?: false; + } + + /** + * @see Serializable::serialize() + * @since 1.26 + * @return string + */ + public function serialize() { + return serialize( [ + 'interface' => $this->interface, + 'language' => $this->language ? $this->language->getCode() : false, + 'key' => $this->key, + 'keysToTry' => $this->keysToTry, + 'parameters' => $this->parameters, + 'format' => $this->format, + 'useDatabase' => $this->useDatabase, + 'titlestr' => $this->title ? $this->title->getFullText() : null, + ] ); + } + + /** + * @see Serializable::unserialize() + * @since 1.26 + * @param string $serialized + */ + public function unserialize( $serialized ) { + $data = unserialize( $serialized ); + if ( !is_array( $data ) ) { + throw new InvalidArgumentException( __METHOD__ . ': Invalid serialized data' ); + } + + $this->interface = $data['interface']; + $this->key = $data['key']; + $this->keysToTry = $data['keysToTry']; + $this->parameters = $data['parameters']; + $this->format = $data['format']; + $this->useDatabase = $data['useDatabase']; + $this->language = $data['language'] ? Language::factory( $data['language'] ) : false; + + if ( isset( $data['titlestr'] ) ) { + $this->title = Title::newFromText( $data['titlestr'] ); + } elseif ( isset( $data['title'] ) && $data['title'] instanceof Title ) { + // Old serializations from before December 2018 + $this->title = $data['title']; + } else { + $this->title = null; // Explicit for sanity + } + } + + /** + * @since 1.24 + * + * @return bool True if this is a multi-key message, that is, if the key provided to the + * constructor was a fallback list of keys to try. + */ + public function isMultiKey() { + return count( $this->keysToTry ) > 1; + } + + /** + * @since 1.24 + * + * @return string[] The list of keys to try when fetching the message text, + * in order of preference. + */ + public function getKeysToTry() { + return $this->keysToTry; + } + + /** + * Returns the message key. + * + * If a list of multiple possible keys was supplied to the constructor, this method may + * return any of these keys. After the message has been fetched, this method will return + * the key that was actually used to fetch the message. + * + * @since 1.21 + * + * @return string + */ + public function getKey() { + return $this->key; + } + + /** + * Returns the message parameters. + * + * @since 1.21 + * + * @return array + */ + public function getParams() { + return $this->parameters; + } + + /** + * Returns the message format. + * + * @since 1.21 + * + * @return string + * @deprecated since 1.29 formatting is not stateful + */ + public function getFormat() { + wfDeprecated( __METHOD__, '1.29' ); + return $this->format; + } + + /** + * Returns the Language of the Message. + * + * @since 1.23 + * + * @return Language + */ + public function getLanguage() { + // Defaults to false which means current user language + return $this->language ?: RequestContext::getMain()->getLanguage(); + } + + /** + * Factory function that is just wrapper for the real constructor. It is + * intended to be used instead of the real constructor, because it allows + * chaining method calls, while new objects don't. + * + * @since 1.17 + * + * @param string|string[]|MessageSpecifier $key + * @param mixed $param,... Parameters as strings. + * + * @return Message + */ + public static function newFromKey( $key /*...*/ ) { + $params = func_get_args(); + array_shift( $params ); + return new self( $key, $params ); + } + + /** + * Transform a MessageSpecifier or a primitive value used interchangeably with + * specifiers (a message key string, or a key + params array) into a proper Message. + * + * Also accepts a MessageSpecifier inside an array: that's not considered a valid format + * but is an easy error to make due to how StatusValue stores messages internally. + * Further array elements are ignored in that case. + * + * @param string|array|MessageSpecifier $value + * @return Message + * @throws InvalidArgumentException + * @since 1.27 + */ + public static function newFromSpecifier( $value ) { + $params = []; + if ( is_array( $value ) ) { + $params = $value; + $value = array_shift( $params ); + } + + if ( $value instanceof Message ) { // Message, RawMessage, ApiMessage, etc + $message = clone $value; + } elseif ( $value instanceof MessageSpecifier ) { + $message = new Message( $value ); + } elseif ( is_string( $value ) ) { + $message = new Message( $value, $params ); + } else { + throw new InvalidArgumentException( __METHOD__ . ': invalid argument type ' + . gettype( $value ) ); + } + + return $message; + } + + /** + * Factory function accepting multiple message keys and returning a message instance + * for the first message which is non-empty. If all messages are empty then an + * instance of the first message key is returned. + * + * @since 1.18 + * + * @param string|string[] $keys,... Message keys, or first argument as an array of all the + * message keys. + * + * @return Message + */ + public static function newFallbackSequence( /*...*/ ) { + $keys = func_get_args(); + if ( func_num_args() == 1 ) { + if ( is_array( $keys[0] ) ) { + // Allow an array to be passed as the first argument instead + $keys = array_values( $keys[0] ); + } else { + // Optimize a single string to not need special fallback handling + $keys = $keys[0]; + } + } + return new self( $keys ); + } + + /** + * Get a title object for a mediawiki message, where it can be found in the mediawiki namespace. + * The title will be for the current language, if the message key is in + * $wgForceUIMsgAsContentMsg it will be append with the language code (except content + * language), because Message::inContentLanguage will also return in user language. + * + * @see $wgForceUIMsgAsContentMsg + * @return Title + * @since 1.26 + */ + public function getTitle() { + global $wgForceUIMsgAsContentMsg; + + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $lang = $this->getLanguage(); + $title = $this->key; + if ( + !$lang->equals( $contLang ) + && in_array( $this->key, (array)$wgForceUIMsgAsContentMsg ) + ) { + $title .= '/' . $lang->getCode(); + } + + return Title::makeTitle( + NS_MEDIAWIKI, $contLang->ucfirst( strtr( $title, ' ', '_' ) ) ); + } + + /** + * Adds parameters to the parameter list of this message. + * + * @since 1.17 + * + * @param mixed $args,... Parameters as strings or arrays from + * Message::numParam() and the like, or a single array of parameters. + * + * @return Message $this + */ + public function params( /*...*/ ) { + $args = func_get_args(); + + // If $args has only one entry and it's an array, then it's either a + // non-varargs call or it happens to be a call with just a single + // "special" parameter. Since the "special" parameters don't have any + // numeric keys, we'll test that to differentiate the cases. + if ( count( $args ) === 1 && isset( $args[0] ) && is_array( $args[0] ) ) { + if ( $args[0] === [] ) { + $args = []; + } else { + foreach ( $args[0] as $key => $value ) { + if ( is_int( $key ) ) { + $args = $args[0]; + break; + } + } + } + } + + $this->parameters = array_merge( $this->parameters, array_values( $args ) ); + return $this; + } + + /** + * Add parameters that are substituted after parsing or escaping. + * In other words the parsing process cannot access the contents + * of this type of parameter, and you need to make sure it is + * sanitized beforehand. The parser will see "$n", instead. + * + * @since 1.17 + * + * @param mixed $params,... Raw parameters as strings, or a single argument that is + * an array of raw parameters. + * + * @return Message $this + */ + public function rawParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::rawParam( $param ); + } + return $this; + } + + /** + * Add parameters that are numeric and will be passed through + * Language::formatNum before substitution + * + * @since 1.18 + * + * @param mixed $param,... Numeric parameters, or a single argument that is + * an array of numeric parameters. + * + * @return Message $this + */ + public function numParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::numParam( $param ); + } + return $this; + } + + /** + * Add parameters that are durations of time and will be passed through + * Language::formatDuration before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Duration parameters, or a single argument that is + * an array of duration parameters. + * + * @return Message $this + */ + public function durationParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::durationParam( $param ); + } + return $this; + } + + /** + * Add parameters that are expiration times and will be passed through + * Language::formatExpiry before substitution + * + * @since 1.22 + * + * @param string|string[] $param,... Expiry parameters, or a single argument that is + * an array of expiry parameters. + * + * @return Message $this + */ + public function expiryParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::expiryParam( $param ); + } + return $this; + } + + /** + * Add parameters that are time periods and will be passed through + * Language::formatTimePeriod before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Time period parameters, or a single argument that is + * an array of time period parameters. + * + * @return Message $this + */ + public function timeperiodParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::timeperiodParam( $param ); + } + return $this; + } + + /** + * Add parameters that are file sizes and will be passed through + * Language::formatSize before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Size parameters, or a single argument that is + * an array of size parameters. + * + * @return Message $this + */ + public function sizeParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::sizeParam( $param ); + } + return $this; + } + + /** + * Add parameters that are bitrates and will be passed through + * Language::formatBitrate before substitution + * + * @since 1.22 + * + * @param int|int[] $param,... Bit rate parameters, or a single argument that is + * an array of bit rate parameters. + * + * @return Message $this + */ + public function bitrateParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::bitrateParam( $param ); + } + return $this; + } + + /** + * Add parameters that are plaintext and will be passed through without + * the content being evaluated. Plaintext parameters are not valid as + * arguments to parser functions. This differs from self::rawParams in + * that the Message class handles escaping to match the output format. + * + * @since 1.25 + * + * @param string|string[] $param,... plaintext parameters, or a single argument that is + * an array of plaintext parameters. + * + * @return Message $this + */ + public function plaintextParams( /*...*/ ) { + $params = func_get_args(); + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + foreach ( $params as $param ) { + $this->parameters[] = self::plaintextParam( $param ); + } + return $this; + } + + /** + * Set the language and the title from a context object + * + * @since 1.19 + * + * @param IContextSource $context + * + * @return Message $this + */ + public function setContext( IContextSource $context ) { + $this->inLanguage( $context->getLanguage() ); + $this->title( $context->getTitle() ); + $this->interface = true; + + return $this; + } + + /** + * Request the message in any language that is supported. + * + * As a side effect interface message status is unconditionally + * turned off. + * + * @since 1.17 + * @param Language|string $lang Language code or Language object. + * @return Message $this + * @throws MWException + */ + public function inLanguage( $lang ) { + $previousLanguage = $this->language; + + if ( $lang instanceof Language ) { + $this->language = $lang; + } elseif ( is_string( $lang ) ) { + if ( !$this->language instanceof Language || $this->language->getCode() != $lang ) { + $this->language = Language::factory( $lang ); + } + } elseif ( $lang instanceof StubUserLang ) { + $this->language = false; + } else { + $type = gettype( $lang ); + throw new MWException( __METHOD__ . " must be " + . "passed a String or Language object; $type given" + ); + } + + if ( $this->language !== $previousLanguage ) { + // The language has changed. Clear the message cache. + $this->message = null; + } + $this->interface = false; + return $this; + } + + /** + * Request the message in the wiki's content language, + * unless it is disabled for this message. + * + * @since 1.17 + * @see $wgForceUIMsgAsContentMsg + * + * @return Message $this + */ + public function inContentLanguage() { + global $wgForceUIMsgAsContentMsg; + if ( in_array( $this->key, (array)$wgForceUIMsgAsContentMsg ) ) { + return $this; + } + + $this->inLanguage( MediaWikiServices::getInstance()->getContentLanguage() ); + return $this; + } + + /** + * Allows manipulating the interface message flag directly. + * Can be used to restore the flag after setting a language. + * + * @since 1.20 + * + * @param bool $interface + * + * @return Message $this + */ + public function setInterfaceMessageFlag( $interface ) { + $this->interface = (bool)$interface; + return $this; + } + + /** + * Enable or disable database use. + * + * @since 1.17 + * + * @param bool $useDatabase + * + * @return Message $this + */ + public function useDatabase( $useDatabase ) { + $this->useDatabase = (bool)$useDatabase; + $this->message = null; + return $this; + } + + /** + * Set the Title object to use as context when transforming the message + * + * @since 1.18 + * + * @param Title $title + * + * @return Message $this + */ + public function title( $title ) { + $this->title = $title; + return $this; + } + + /** + * Returns the message as a Content object. + * + * @return Content + */ + public function content() { + if ( !$this->content ) { + $this->content = new MessageContent( $this ); + } + + return $this->content; + } + + /** + * Returns the message parsed from wikitext to HTML. + * + * @since 1.17 + * + * @param string|null $format One of the FORMAT_* constants. Null means use whatever was used + * the last time (this is for B/C and should be avoided). + * + * @return string HTML + * @suppress SecurityCheck-DoubleEscaped phan false positive + */ + public function toString( $format = null ) { + if ( $format === null ) { + $ex = new LogicException( __METHOD__ . ' using implicit format: ' . $this->format ); + LoggerFactory::getInstance( 'message-format' )->warning( + $ex->getMessage(), [ 'exception' => $ex, 'format' => $this->format, 'key' => $this->key ] ); + $format = $this->format; + } + $string = $this->fetchMessage(); + + if ( $string === false ) { + // Err on the side of safety, ensure that the output + // is always html safe in the event the message key is + // missing, since in that case its highly likely the + // message key is user-controlled. + // '⧼' is used instead of '<' to side-step any + // double-escaping issues. + // (Keep synchronised with mw.Message#toString in JS.) + return '⧼' . htmlspecialchars( $this->key ) . '⧽'; + } + + # Replace $* with a list of parameters for &uselang=qqx. + if ( strpos( $string, '$*' ) !== false ) { + $paramlist = ''; + if ( $this->parameters !== [] ) { + $paramlist = ': $' . implode( ', $', range( 1, count( $this->parameters ) ) ); + } + $string = str_replace( '$*', $paramlist, $string ); + } + + # Replace parameters before text parsing + $string = $this->replaceParameters( $string, 'before', $format ); + + # Maybe transform using the full parser + if ( $format === self::FORMAT_PARSE ) { + $string = $this->parseText( $string ); + $string = Parser::stripOuterParagraph( $string ); + } elseif ( $format === self::FORMAT_BLOCK_PARSE ) { + $string = $this->parseText( $string ); + } elseif ( $format === self::FORMAT_TEXT ) { + $string = $this->transformText( $string ); + } elseif ( $format === self::FORMAT_ESCAPED ) { + $string = $this->transformText( $string ); + $string = htmlspecialchars( $string, ENT_QUOTES, 'UTF-8', false ); + } + + # Raw parameter replacement + $string = $this->replaceParameters( $string, 'after', $format ); + + return $string; + } + + /** + * Magic method implementation of the above (for PHP >= 5.2.0), so we can do, eg: + * $foo = new Message( $key ); + * $string = "$foo"; + * + * @since 1.18 + * + * @return string + */ + public function __toString() { + // PHP doesn't allow __toString to throw exceptions and will + // trigger a fatal error if it does. So, catch any exceptions. + + try { + return $this->toString( self::FORMAT_PARSE ); + } catch ( Exception $ex ) { + try { + trigger_error( "Exception caught in " . __METHOD__ . " (message " . $this->key . "): " + . $ex, E_USER_WARNING ); + } catch ( Exception $ex ) { + // Doh! Cause a fatal error after all? + } + + return '⧼' . htmlspecialchars( $this->key ) . '⧽'; + } + } + + /** + * Fully parse the text from wikitext to HTML. + * + * @since 1.17 + * + * @return string Parsed HTML. + */ + public function parse() { + $this->format = self::FORMAT_PARSE; + return $this->toString( self::FORMAT_PARSE ); + } + + /** + * Returns the message text. {{-transformation is done. + * + * @since 1.17 + * + * @return string Unescaped message text. + */ + public function text() { + $this->format = self::FORMAT_TEXT; + return $this->toString( self::FORMAT_TEXT ); + } + + /** + * Returns the message text as-is, only parameters are substituted. + * + * @since 1.17 + * + * @return string Unescaped untransformed message text. + */ + public function plain() { + $this->format = self::FORMAT_PLAIN; + return $this->toString( self::FORMAT_PLAIN ); + } + + /** + * Returns the parsed message text which is always surrounded by a block element. + * + * @since 1.17 + * + * @return string HTML + */ + public function parseAsBlock() { + $this->format = self::FORMAT_BLOCK_PARSE; + return $this->toString( self::FORMAT_BLOCK_PARSE ); + } + + /** + * Returns the message text. {{-transformation is done and the result + * is escaped excluding any raw parameters. + * + * @since 1.17 + * + * @return string Escaped message text. + */ + public function escaped() { + $this->format = self::FORMAT_ESCAPED; + return $this->toString( self::FORMAT_ESCAPED ); + } + + /** + * Check whether a message key has been defined currently. + * + * @since 1.17 + * + * @return bool + */ + public function exists() { + return $this->fetchMessage() !== false; + } + + /** + * Check whether a message does not exist, or is an empty string + * + * @since 1.18 + * @todo FIXME: Merge with isDisabled()? + * + * @return bool + */ + public function isBlank() { + $message = $this->fetchMessage(); + return $message === false || $message === ''; + } + + /** + * Check whether a message does not exist, is an empty string, or is "-". + * + * @since 1.18 + * + * @return bool + */ + public function isDisabled() { + $message = $this->fetchMessage(); + return $message === false || $message === '' || $message === '-'; + } + + /** + * @since 1.17 + * + * @param mixed $raw + * + * @return array Array with a single "raw" key. + */ + public static function rawParam( $raw ) { + return [ 'raw' => $raw ]; + } + + /** + * @since 1.18 + * + * @param mixed $num + * + * @return array Array with a single "num" key. + */ + public static function numParam( $num ) { + return [ 'num' => $num ]; + } + + /** + * @since 1.22 + * + * @param int $duration + * + * @return int[] Array with a single "duration" key. + */ + public static function durationParam( $duration ) { + return [ 'duration' => $duration ]; + } + + /** + * @since 1.22 + * + * @param string $expiry + * + * @return string[] Array with a single "expiry" key. + */ + public static function expiryParam( $expiry ) { + return [ 'expiry' => $expiry ]; + } + + /** + * @since 1.22 + * + * @param int $period + * + * @return int[] Array with a single "period" key. + */ + public static function timeperiodParam( $period ) { + return [ 'period' => $period ]; + } + + /** + * @since 1.22 + * + * @param int $size + * + * @return int[] Array with a single "size" key. + */ + public static function sizeParam( $size ) { + return [ 'size' => $size ]; + } + + /** + * @since 1.22 + * + * @param int $bitrate + * + * @return int[] Array with a single "bitrate" key. + */ + public static function bitrateParam( $bitrate ) { + return [ 'bitrate' => $bitrate ]; + } + + /** + * @since 1.25 + * + * @param string $plaintext + * + * @return string[] Array with a single "plaintext" key. + */ + public static function plaintextParam( $plaintext ) { + return [ 'plaintext' => $plaintext ]; + } + + /** + * @since 1.29 + * + * @param array $list + * @param string $type 'comma', 'semicolon', 'pipe', 'text' + * @return array Array with "list" and "type" keys. + */ + public static function listParam( array $list, $type = 'text' ) { + if ( !isset( self::$listTypeMap[$type] ) ) { + throw new InvalidArgumentException( + "Invalid type '$type'. Known types are: " . implode( ', ', array_keys( self::$listTypeMap ) ) + ); + } + return [ 'list' => $list, 'type' => $type ]; + } + + /** + * Substitutes any parameters into the message text. + * + * @since 1.17 + * + * @param string $message The message text. + * @param string $type Either "before" or "after". + * @param string $format One of the FORMAT_* constants. + * + * @return string + */ + protected function replaceParameters( $message, $type, $format ) { + // A temporary marker for $1 parameters that is only valid + // in non-attribute contexts. However if the entire message is escaped + // then we don't want to use it because it will be mangled in all contexts + // and its unnessary as ->escaped() messages aren't html. + $marker = $format === self::FORMAT_ESCAPED ? '$' : '$\'"'; + $replacementKeys = []; + foreach ( $this->parameters as $n => $param ) { + list( $paramType, $value ) = $this->extractParam( $param, $format ); + if ( $type === 'before' ) { + if ( $paramType === 'before' ) { + $replacementKeys['$' . ( $n + 1 )] = $value; + } else /* $paramType === 'after' */ { + // To protect against XSS from replacing parameters + // inside html attributes, we convert $1 to $'"1. + // In the event that one of the parameters ends up + // in an attribute, either the ' or the " will be + // escaped, breaking the replacement and avoiding XSS. + $replacementKeys['$' . ( $n + 1 )] = $marker . ( $n + 1 ); + } + } elseif ( $paramType === 'after' ) { + $replacementKeys[$marker . ( $n + 1 )] = $value; + } + } + return strtr( $message, $replacementKeys ); + } + + /** + * Extracts the parameter type and preprocessed the value if needed. + * + * @since 1.18 + * + * @param mixed $param Parameter as defined in this class. + * @param string $format One of the FORMAT_* constants. + * + * @return array Array with the parameter type (either "before" or "after") and the value. + */ + protected function extractParam( $param, $format ) { + if ( is_array( $param ) ) { + if ( isset( $param['raw'] ) ) { + return [ 'after', $param['raw'] ]; + } elseif ( isset( $param['num'] ) ) { + // Replace number params always in before step for now. + // No support for combined raw and num params + return [ 'before', $this->getLanguage()->formatNum( $param['num'] ) ]; + } elseif ( isset( $param['duration'] ) ) { + return [ 'before', $this->getLanguage()->formatDuration( $param['duration'] ) ]; + } elseif ( isset( $param['expiry'] ) ) { + return [ 'before', $this->getLanguage()->formatExpiry( $param['expiry'] ) ]; + } elseif ( isset( $param['period'] ) ) { + return [ 'before', $this->getLanguage()->formatTimePeriod( $param['period'] ) ]; + } elseif ( isset( $param['size'] ) ) { + return [ 'before', $this->getLanguage()->formatSize( $param['size'] ) ]; + } elseif ( isset( $param['bitrate'] ) ) { + return [ 'before', $this->getLanguage()->formatBitrate( $param['bitrate'] ) ]; + } elseif ( isset( $param['plaintext'] ) ) { + return [ 'after', $this->formatPlaintext( $param['plaintext'], $format ) ]; + } elseif ( isset( $param['list'] ) ) { + return $this->formatListParam( $param['list'], $param['type'], $format ); + } else { + if ( !is_scalar( $param ) ) { + $param = serialize( $param ); + } + LoggerFactory::getInstance( 'Bug58676' )->warning( + 'Invalid parameter for message "{msgkey}": {param}', + [ + 'exception' => new Exception, + 'msgkey' => $this->getKey(), + 'param' => htmlspecialchars( $param ), + ] + ); + + return [ 'before', '[INVALID]' ]; + } + } elseif ( $param instanceof Message ) { + // Match language, flags, etc. to the current message. + $msg = clone $param; + if ( $msg->language !== $this->language || $msg->useDatabase !== $this->useDatabase ) { + // Cache depends on these parameters + $msg->message = null; + } + $msg->interface = $this->interface; + $msg->language = $this->language; + $msg->useDatabase = $this->useDatabase; + $msg->title = $this->title; + + // DWIM + if ( $format === 'block-parse' ) { + $format = 'parse'; + } + $msg->format = $format; + + // Message objects should not be before parameters because + // then they'll get double escaped. If the message needs to be + // escaped, it'll happen right here when we call toString(). + return [ 'after', $msg->toString( $format ) ]; + } else { + return [ 'before', $param ]; + } + } + + /** + * Wrapper for what ever method we use to parse wikitext. + * + * @since 1.17 + * + * @param string $string Wikitext message contents. + * + * @return string Wikitext parsed into HTML. + */ + protected function parseText( $string ) { + $out = MessageCache::singleton()->parse( + $string, + $this->title, + /*linestart*/true, + $this->interface, + $this->getLanguage() + ); + + return $out instanceof ParserOutput + ? $out->getText( [ + 'enableSectionEditLinks' => false, + // Wrapping messages in an extra
is probably not expected. If + // they're outside the content area they probably shouldn't be + // targeted by CSS that's targeting the parser output, and if + // they're inside they already are from the outer div. + 'unwrap' => true, + ] ) + : $out; + } + + /** + * Wrapper for what ever method we use to {{-transform wikitext. + * + * @since 1.17 + * + * @param string $string Wikitext message contents. + * + * @return string Wikitext with {{-constructs replaced with their values. + */ + protected function transformText( $string ) { + return MessageCache::singleton()->transform( + $string, + $this->interface, + $this->getLanguage(), + $this->title + ); + } + + /** + * Wrapper for what ever method we use to get message contents. + * + * @since 1.17 + * + * @return string + * @throws MWException If message key array is empty. + */ + protected function fetchMessage() { + if ( $this->message === null ) { + $cache = MessageCache::singleton(); + + foreach ( $this->keysToTry as $key ) { + $message = $cache->get( $key, $this->useDatabase, $this->getLanguage() ); + if ( $message !== false && $message !== '' ) { + break; + } + } + + // NOTE: The constructor makes sure keysToTry isn't empty, + // so we know that $key and $message are initialized. + $this->key = $key; + $this->message = $message; + } + return $this->message; + } + + /** + * Formats a message parameter wrapped with 'plaintext'. Ensures that + * the entire string is displayed unchanged when displayed in the output + * format. + * + * @since 1.25 + * + * @param string $plaintext String to ensure plaintext output of + * @param string $format One of the FORMAT_* constants. + * + * @return string Input plaintext encoded for output to $format + */ + protected function formatPlaintext( $plaintext, $format ) { + switch ( $format ) { + case self::FORMAT_TEXT: + case self::FORMAT_PLAIN: + return $plaintext; + + case self::FORMAT_PARSE: + case self::FORMAT_BLOCK_PARSE: + case self::FORMAT_ESCAPED: + default: + return htmlspecialchars( $plaintext, ENT_QUOTES ); + } + } + + /** + * Formats a list of parameters as a concatenated string. + * @since 1.29 + * @param array $params + * @param string $listType + * @param string $format One of the FORMAT_* constants. + * @return array Array with the parameter type (either "before" or "after") and the value. + */ + protected function formatListParam( array $params, $listType, $format ) { + if ( !isset( self::$listTypeMap[$listType] ) ) { + $warning = 'Invalid list type for message "' . $this->getKey() . '": ' + . htmlspecialchars( $listType ) + . ' (params are ' . htmlspecialchars( serialize( $params ) ) . ')'; + trigger_error( $warning, E_USER_WARNING ); + $e = new Exception; + wfDebugLog( 'Bug58676', $warning . "\n" . $e->getTraceAsString() ); + return [ 'before', '[INVALID]' ]; + } + $func = self::$listTypeMap[$listType]; + + // Handle an empty list sensibly + if ( !$params ) { + return [ 'before', $this->getLanguage()->$func( [] ) ]; + } + + // First, determine what kinds of list items we have + $types = []; + $vars = []; + $list = []; + foreach ( $params as $n => $p ) { + list( $type, $value ) = $this->extractParam( $p, $format ); + $types[$type] = true; + $list[] = $value; + $vars[] = '$' . ( $n + 1 ); + } + + // Easy case: all are 'before' or 'after', so just join the + // values and use the same type. + if ( count( $types ) === 1 ) { + return [ key( $types ), $this->getLanguage()->$func( $list ) ]; + } + + // Hard case: We need to process each value per its type, then + // return the concatenated values as 'after'. We handle this by turning + // the list into a RawMessage and processing that as a parameter. + $vars = $this->getLanguage()->$func( $vars ); + return $this->extractParam( new RawMessage( $vars, $params ), $format ); + } +} diff --git a/includes/language/MessageLocalizer.php b/includes/language/MessageLocalizer.php new file mode 100644 index 0000000000..9a1796b140 --- /dev/null +++ b/includes/language/MessageLocalizer.php @@ -0,0 +1,43 @@ + (uses RFC 3986) - * - headers :
- * - body : source to get the HTTP request body from; - * this can simply be a string (always), a resource for - * PUT requests, and a field/value array for POST request; - * array bodies are encoded as multipart/form-data and strings - * use application/x-www-form-urlencoded (headers sent automatically) - * - stream : resource to stream the HTTP response body to - * - proxy : HTTP proxy to use - * - flags : map of boolean flags which supports: - * - relayResponseHeaders : write out header via header() - * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'. - * - * @since 1.23 - */ -class MultiHttpClient implements LoggerAwareInterface { - /** @var resource */ - protected $multiHandle = null; // curl_multi handle - /** @var string|null SSL certificates path */ - protected $caBundlePath; - /** @var float */ - protected $connTimeout = 10; - /** @var float */ - protected $reqTimeout = 300; - /** @var bool */ - protected $usePipelining = false; - /** @var int */ - protected $maxConnsPerHost = 50; - /** @var string|null proxy */ - protected $proxy; - /** @var string */ - protected $userAgent = 'wikimedia/multi-http-client v1.0'; - /** @var LoggerInterface */ - protected $logger; - - // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect - // timeouts are periodically polled instead of being accurately respected. - // The select timeout is set to the minimum timeout multiplied by this factor. - const TIMEOUT_ACCURACY_FACTOR = 0.1; - - /** - * @param array $options - * - connTimeout : default connection timeout (seconds) - * - reqTimeout : default request timeout (seconds) - * - proxy : HTTP proxy to use - * - usePipelining : whether to use HTTP pipelining if possible (for all hosts) - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * - userAgent : The User-Agent header value to send - * - logger : a \Psr\Log\LoggerInterface instance for debug logging - * - caBundlePath : path to specific Certificate Authority bundle (if any) - * @throws Exception - */ - public function __construct( array $options ) { - if ( isset( $options['caBundlePath'] ) ) { - $this->caBundlePath = $options['caBundlePath']; - if ( !file_exists( $this->caBundlePath ) ) { - throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath ); - } - } - static $opts = [ - 'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost', - 'proxy', 'userAgent', 'logger' - ]; - foreach ( $opts as $key ) { - if ( isset( $options[$key] ) ) { - $this->$key = $options[$key]; - } - } - if ( $this->logger === null ) { - $this->logger = new NullLogger; - } - } - - /** - * Execute an HTTP(S) request - * - * This method returns a response map of: - * - code : HTTP response code or 0 if there was a serious error - * - reason : HTTP response reason (empty if there was a serious error) - * - headers :
- * - body : HTTP response body or resource (if "stream" was set) - * - error : Any error string - * The map also stores integer-indexed copies of these values. This lets callers do: - * @code - * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req ); - * @endcode - * @param array $req HTTP request array - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * @return array Response array for request - */ - public function run( array $req, array $opts = [] ) { - return $this->runMulti( [ $req ], $opts )[0]['response']; - } - - /** - * Execute a set of HTTP(S) requests. - * - * If curl is available, requests will be made concurrently. - * Otherwise, they will be made serially. - * - * The maps are returned by this method with the 'response' field set to a map of: - * - code : HTTP response code or 0 if there was a serious error - * - reason : HTTP response reason (empty if there was a serious error) - * - headers :
- * - body : HTTP response body or resource (if "stream" was set) - * - error : Any error string - * The map also stores integer-indexed copies of these values. This lets callers do: - * @code - * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response']; - * @endcode - * All headers in the 'headers' field are normalized to use lower case names. - * This is true for the request headers and the response headers. Integer-indexed - * method/URL entries will also be changed to use the corresponding string keys. - * - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * - usePipelining : whether to use HTTP pipelining if possible - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * @return array $reqs With response array populated for each - * @throws Exception - */ - public function runMulti( array $reqs, array $opts = [] ) { - $this->normalizeRequests( $reqs ); - if ( $this->isCurlEnabled() ) { - return $this->runMultiCurl( $reqs, $opts ); - } else { - return $this->runMultiHttp( $reqs, $opts ); - } - } - - /** - * Determines if the curl extension is available - * - * @return bool true if curl is available, false otherwise. - */ - protected function isCurlEnabled() { - return extension_loaded( 'curl' ); - } - - /** - * Execute a set of HTTP(S) requests concurrently - * - * @see MultiHttpClient::runMulti() - * - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * - usePipelining : whether to use HTTP pipelining if possible - * - maxConnsPerHost : maximum number of concurrent connections (per host) - * @return array $reqs With response array populated for each - * @throws Exception - */ - private function runMultiCurl( array $reqs, array $opts = [] ) { - $chm = $this->getCurlMulti(); - - $selectTimeout = $this->getSelectTimeout( $opts ); - - // Add all of the required cURL handles... - $handles = []; - foreach ( $reqs as $index => &$req ) { - $handles[$index] = $this->getCurlHandle( $req, $opts ); - if ( count( $reqs ) > 1 ) { - // https://github.com/guzzle/guzzle/issues/349 - curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true ); - } - } - unset( $req ); // don't assign over this by accident - - $indexes = array_keys( $reqs ); - if ( isset( $opts['usePipelining'] ) ) { - curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] ); - } - if ( isset( $opts['maxConnsPerHost'] ) ) { - // Keep these sockets around as they may be needed later in the request - curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] ); - } - - // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS) - $batches = array_chunk( $indexes, $this->maxConnsPerHost ); - $infos = []; - - foreach ( $batches as $batch ) { - // Attach all cURL handles for this batch - foreach ( $batch as $index ) { - curl_multi_add_handle( $chm, $handles[$index] ); - } - // Execute the cURL handles concurrently... - $active = null; // handles still being processed - do { - // Do any available work... - do { - $mrc = curl_multi_exec( $chm, $active ); - $info = curl_multi_info_read( $chm ); - if ( $info !== false ) { - $infos[(int)$info['handle']] = $info; - } - } while ( $mrc == CURLM_CALL_MULTI_PERFORM ); - // Wait (if possible) for available work... - if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) { - // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html - usleep( 5000 ); // 5ms - } - } while ( $active > 0 && $mrc == CURLM_OK ); - } - - // Remove all of the added cURL handles and check for errors... - foreach ( $reqs as $index => &$req ) { - $ch = $handles[$index]; - curl_multi_remove_handle( $chm, $ch ); - - if ( isset( $infos[(int)$ch] ) ) { - $info = $infos[(int)$ch]; - $errno = $info['result']; - if ( $errno !== 0 ) { - $req['response']['error'] = "(curl error: $errno)"; - if ( function_exists( 'curl_strerror' ) ) { - $req['response']['error'] .= " " . curl_strerror( $errno ); - } - $this->logger->warning( "Error fetching URL \"{$req['url']}\": " . - $req['response']['error'] ); - } - } else { - $req['response']['error'] = "(curl error: no status set)"; - } - - // For convenience with the list() operator - $req['response'][0] = $req['response']['code']; - $req['response'][1] = $req['response']['reason']; - $req['response'][2] = $req['response']['headers']; - $req['response'][3] = $req['response']['body']; - $req['response'][4] = $req['response']['error']; - curl_close( $ch ); - // Close any string wrapper file handles - if ( isset( $req['_closeHandle'] ) ) { - fclose( $req['_closeHandle'] ); - unset( $req['_closeHandle'] ); - } - } - unset( $req ); // don't assign over this by accident - - // Restore the default settings - curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining ); - curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); - - return $reqs; - } - - /** - * @param array &$req HTTP request map - * @param array $opts - * - connTimeout : default connection timeout - * - reqTimeout : default request timeout - * @return resource - * @throws Exception - */ - protected function getCurlHandle( array &$req, array $opts = [] ) { - $ch = curl_init(); - - curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS, - ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 ); - curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy ); - curl_setopt( $ch, CURLOPT_TIMEOUT_MS, - ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 ); - curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 ); - curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 ); - curl_setopt( $ch, CURLOPT_HEADER, 0 ); - if ( !is_null( $this->caBundlePath ) ) { - curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true ); - curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath ); - } - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); - - $url = $req['url']; - $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); - if ( $query != '' ) { - $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; - } - curl_setopt( $ch, CURLOPT_URL, $url ); - - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] ); - if ( $req['method'] === 'HEAD' ) { - curl_setopt( $ch, CURLOPT_NOBODY, 1 ); - } - - if ( $req['method'] === 'PUT' ) { - curl_setopt( $ch, CURLOPT_PUT, 1 ); - if ( is_resource( $req['body'] ) ) { - curl_setopt( $ch, CURLOPT_INFILE, $req['body'] ); - if ( isset( $req['headers']['content-length'] ) ) { - curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] ); - } elseif ( isset( $req['headers']['transfer-encoding'] ) && - $req['headers']['transfer-encoding'] === 'chunks' - ) { - curl_setopt( $ch, CURLOPT_UPLOAD, true ); - } else { - throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." ); - } - } elseif ( $req['body'] !== '' ) { - $fp = fopen( "php://temp", "wb+" ); - fwrite( $fp, $req['body'], strlen( $req['body'] ) ); - rewind( $fp ); - curl_setopt( $ch, CURLOPT_INFILE, $fp ); - curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) ); - $req['_closeHandle'] = $fp; // remember to close this later - } else { - curl_setopt( $ch, CURLOPT_INFILESIZE, 0 ); - } - curl_setopt( $ch, CURLOPT_READFUNCTION, - function ( $ch, $fd, $length ) { - $data = fread( $fd, $length ); - $len = strlen( $data ); - return $data; - } - ); - } elseif ( $req['method'] === 'POST' ) { - curl_setopt( $ch, CURLOPT_POST, 1 ); - // Don't interpret POST parameters starting with '@' as file uploads, because this - // makes it impossible to POST plain values starting with '@' (and causes security - // issues potentially exposing the contents of local files). - curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] ); - } else { - if ( is_resource( $req['body'] ) || $req['body'] !== '' ) { - throw new Exception( "HTTP body specified for a non PUT/POST request." ); - } - $req['headers']['content-length'] = 0; - } - - if ( !isset( $req['headers']['user-agent'] ) ) { - $req['headers']['user-agent'] = $this->userAgent; - } - - $headers = []; - foreach ( $req['headers'] as $name => $value ) { - if ( strpos( $name, ': ' ) ) { - throw new Exception( "Headers cannot have ':' in the name." ); - } - $headers[] = $name . ': ' . trim( $value ); - } - curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); - - curl_setopt( $ch, CURLOPT_HEADERFUNCTION, - function ( $ch, $header ) use ( &$req ) { - if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) { - header( $header ); - } - $length = strlen( $header ); - $matches = []; - if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) { - $req['response']['code'] = (int)$matches[2]; - $req['response']['reason'] = trim( $matches[3] ); - return $length; - } - if ( strpos( $header, ":" ) === false ) { - return $length; - } - list( $name, $value ) = explode( ":", $header, 2 ); - $name = strtolower( $name ); - $value = trim( $value ); - if ( isset( $req['response']['headers'][$name] ) ) { - $req['response']['headers'][$name] .= ', ' . $value; - } else { - $req['response']['headers'][$name] = $value; - } - return $length; - } - ); - - if ( isset( $req['stream'] ) ) { - // Don't just use CURLOPT_FILE as that might give: - // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE* - // The callback here handles both normal files and php://temp handles. - curl_setopt( $ch, CURLOPT_WRITEFUNCTION, - function ( $ch, $data ) use ( &$req ) { - return fwrite( $req['stream'], $data ); - } - ); - } else { - curl_setopt( $ch, CURLOPT_WRITEFUNCTION, - function ( $ch, $data ) use ( &$req ) { - $req['response']['body'] .= $data; - return strlen( $data ); - } - ); - } - - return $ch; - } - - /** - * @return resource - * @throws Exception - */ - protected function getCurlMulti() { - if ( !$this->multiHandle ) { - if ( !function_exists( 'curl_multi_init' ) ) { - throw new Exception( "PHP cURL function curl_multi_init missing. " . - "Check https://www.mediawiki.org/wiki/Manual:CURL" ); - } - $cmh = curl_multi_init(); - curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining ); - curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); - $this->multiHandle = $cmh; - } - return $this->multiHandle; - } - - /** - * Execute a set of HTTP(S) requests sequentially. - * - * @see MultiHttpClient::runMulti() - * @todo Remove dependency on MediaWikiServices: use a separate HTTP client - * library or copy code from PhpHttpRequest - * @param array $reqs Map of HTTP request arrays - * @param array $opts - * - connTimeout : connection timeout per request (seconds) - * - reqTimeout : post-connection timeout per request (seconds) - * @return array $reqs With response array populated for each - * @throws Exception - */ - private function runMultiHttp( array $reqs, array $opts = [] ) { - $httpOptions = [ - 'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout, - 'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout, - 'logger' => $this->logger, - 'caInfo' => $this->caBundlePath, - ]; - foreach ( $reqs as &$req ) { - $reqOptions = $httpOptions + [ - 'method' => $req['method'], - 'proxy' => $req['proxy'] ?? $this->proxy, - 'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent, - 'postData' => $req['body'], - ]; - - $url = $req['url']; - $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); - if ( $query != '' ) { - $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; - } - - $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( - $url, $reqOptions ); - $sv = $httpRequest->execute()->getStatusValue(); - - $respHeaders = array_map( - function ( $v ) { - return implode( ', ', $v ); - }, - $httpRequest->getResponseHeaders() ); - - $req['response'] = [ - 'code' => $httpRequest->getStatus(), - 'reason' => '', - 'headers' => $respHeaders, - 'body' => $httpRequest->getContent(), - 'error' => '', - ]; - - if ( !$sv->isOk() ) { - $svErrors = $sv->getErrors(); - if ( isset( $svErrors[0] ) ) { - $req['response']['error'] = $svErrors[0]['message']; - - // param values vary per failure type (ex. unknown host vs unknown page) - if ( isset( $svErrors[0]['params'][0] ) ) { - if ( is_numeric( $svErrors[0]['params'][0] ) ) { - if ( isset( $svErrors[0]['params'][1] ) ) { - $req['response']['reason'] = $svErrors[0]['params'][1]; - } - } else { - $req['response']['reason'] = $svErrors[0]['params'][0]; - } - } - } - } - - $req['response'][0] = $req['response']['code']; - $req['response'][1] = $req['response']['reason']; - $req['response'][2] = $req['response']['headers']; - $req['response'][3] = $req['response']['body']; - $req['response'][4] = $req['response']['error']; - } - - return $reqs; - } - - /** - * Normalize request information - * - * @param array $reqs the requests to normalize - */ - private function normalizeRequests( array &$reqs ) { - foreach ( $reqs as &$req ) { - $req['response'] = [ - 'code' => 0, - 'reason' => '', - 'headers' => [], - 'body' => '', - 'error' => '' - ]; - if ( isset( $req[0] ) ) { - $req['method'] = $req[0]; // short-form - unset( $req[0] ); - } - if ( isset( $req[1] ) ) { - $req['url'] = $req[1]; // short-form - unset( $req[1] ); - } - if ( !isset( $req['method'] ) ) { - throw new Exception( "Request has no 'method' field set." ); - } elseif ( !isset( $req['url'] ) ) { - throw new Exception( "Request has no 'url' field set." ); - } - $this->logger->debug( "{$req['method']}: {$req['url']}" ); - $req['query'] = $req['query'] ?? []; - $headers = []; // normalized headers - if ( isset( $req['headers'] ) ) { - foreach ( $req['headers'] as $name => $value ) { - $headers[strtolower( $name )] = $value; - } - } - $req['headers'] = $headers; - if ( !isset( $req['body'] ) ) { - $req['body'] = ''; - $req['headers']['content-length'] = 0; - } - $req['flags'] = $req['flags'] ?? []; - } - } - - /** - * Get a suitable select timeout for the given options. - * - * @param array $opts - * @return float - */ - private function getSelectTimeout( $opts ) { - $connTimeout = $opts['connTimeout'] ?? $this->connTimeout; - $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout; - $timeouts = array_filter( [ $connTimeout, $reqTimeout ] ); - if ( count( $timeouts ) === 0 ) { - return 1; - } - - $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR; - // Minimum 10us for sanity - if ( $selectTimeout < 10e-6 ) { - $selectTimeout = 10e-6; - } - return $selectTimeout; - } - - /** - * Register a logger - * - * @param LoggerInterface $logger - */ - public function setLogger( LoggerInterface $logger ) { - $this->logger = $logger; - } - - function __destruct() { - if ( $this->multiHandle ) { - curl_multi_close( $this->multiHandle ); - } - } -} diff --git a/includes/libs/ParamValidator/Callbacks.php b/includes/libs/ParamValidator/Callbacks.php new file mode 100644 index 0000000000..d94a81fbd5 --- /dev/null +++ b/includes/libs/ParamValidator/Callbacks.php @@ -0,0 +1,78 @@ + [ 'class' => TypeDef\BooleanDef::class ], + 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ], + 'integer' => [ 'class' => TypeDef\IntegerDef::class ], + 'limit' => [ 'class' => TypeDef\LimitDef::class ], + 'float' => [ 'class' => TypeDef\FloatDef::class ], + 'double' => [ 'class' => TypeDef\FloatDef::class ], + 'string' => [ 'class' => TypeDef\StringDef::class ], + 'password' => [ 'class' => TypeDef\PasswordDef::class ], + 'NULL' => [ + 'class' => TypeDef\StringDef::class, + 'args' => [ [ + 'allowEmptyWhenRequired' => true, + ] ], + ], + 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ], + 'upload' => [ 'class' => TypeDef\UploadDef::class ], + 'enum' => [ 'class' => TypeDef\EnumDef::class ], + ]; + + /** @var Callbacks */ + private $callbacks; + + /** @var ObjectFactory */ + private $objectFactory; + + /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */ + private $typeDefs = []; + + /** @var int Default values for PARAM_ISMULTI_LIMIT1 */ + private $ismultiLimit1; + + /** @var int Default values for PARAM_ISMULTI_LIMIT2 */ + private $ismultiLimit2; + + /** + * @param Callbacks $callbacks + * @param ObjectFactory $objectFactory To turn specs into TypeDef objects + * @param array $options Associative array of additional settings + * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used. + * Pass an empty array if you want to start with no registered types. + * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and + * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`. + */ + public function __construct( + Callbacks $callbacks, + ObjectFactory $objectFactory, + array $options = [] + ) { + $this->callbacks = $callbacks; + $this->objectFactory = $objectFactory; + + $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES ); + $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50; + $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500; + } + + /** + * List known type names + * @return string[] + */ + public function knownTypes() { + return array_keys( $this->typeDefs ); + } + + /** + * Register multiple type handlers + * + * @see addTypeDef() + * @param array $typeDefs Associative array mapping `$name` to `$typeDef`. + */ + public function addTypeDefs( array $typeDefs ) { + foreach ( $typeDefs as $name => $def ) { + $this->addTypeDef( $name, $def ); + } + } + + /** + * Register a type handler + * + * To allow code to omit PARAM_TYPE in settings arrays to derive the type + * from PARAM_DEFAULT, it is strongly recommended that the following types be + * registered: "boolean", "integer", "double", "string", "NULL", and "enum". + * + * When using ObjectFactory specs, the following extra arguments are passed: + * - The Callbacks object for this ParamValidator instance. + * + * @param string $name Type name + * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one. + */ + public function addTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array' ] ), + $typeDef, + '$typeDef' + ); + + if ( isset( $this->typeDefs[$name] ) ) { + throw new InvalidArgumentException( "Type '$name' is already registered" ); + } + $this->typeDefs[$name] = $typeDef; + } + + /** + * Register a type handler, overriding any existing handler + * @see addTypeDef + * @param string $name Type name + * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type. + */ + public function overrideTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array', 'null' ] ), + $typeDef, + '$typeDef' + ); + + if ( $typeDef === null ) { + unset( $this->typeDefs[$name] ); + } else { + $this->typeDefs[$name] = $typeDef; + } + } + + /** + * Test if a type is registered + * @param string $name Type name + * @return bool + */ + public function hasTypeDef( $name ) { + return isset( $this->typeDefs[$name] ); + } + + /** + * Get the TypeDef for a type + * @param string|array $type Any array is considered equivalent to the string "enum". + * @return TypeDef|null + */ + public function getTypeDef( $type ) { + if ( is_array( $type ) ) { + $type = 'enum'; + } + + if ( !isset( $this->typeDefs[$type] ) ) { + return null; + } + + $def = $this->typeDefs[$type]; + if ( !$def instanceof TypeDef ) { + $def = $this->objectFactory->createObject( $def, [ + 'extraArgs' => [ $this->callbacks ], + 'assertClass' => TypeDef::class, + ] ); + $this->typeDefs[$type] = $def; + } + + return $def; + } + + /** + * Normalize a parameter settings array + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @return array + */ + public function normalizeSettings( $settings ) { + // Shorthand + if ( !is_array( $settings ) ) { + $settings = [ + self::PARAM_DEFAULT => $settings, + ]; + } + + // When type is not given, determine it from the type of the PARAM_DEFAULT + if ( !isset( $settings[self::PARAM_TYPE] ) ) { + $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null ); + } + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( $typeDef ) { + $settings = $typeDef->normalizeSettings( $settings ); + } + + return $settings; + } + + /** + * Fetch and valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * @return mixed Validated parameter value + * @throws ValidationException if the value is invalid + */ + public function getValue( $name, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + $value = $typeDef->getValue( $name, $settings, $options ); + + if ( $value !== null ) { + if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-sensitive', [] ), + $options + ); + } + + // Set a warning if a deprecated parameter has been passed + if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-deprecated', [] ), + $options + ); + } + } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) { + $value = $settings[self::PARAM_DEFAULT]; + } + + return $this->validateValue( $name, $value, $settings, $options ); + } + + /** + * Valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param null|mixed $value Parameter value + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * - An additional option, 'values-list', will be set when processing the + * values of a multi-valued parameter. + * @return mixed Validated parameter value(s) + * @throws ValidationException if the value is invalid + */ + public function validateValue( $name, $value, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + if ( $value === null ) { + if ( !empty( $settings[self::PARAM_REQUIRED] ) ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + return null; + } + + // Non-multi + if ( empty( $settings[self::PARAM_ISMULTI] ) ) { + return $typeDef->validate( $name, $value, $settings, $options ); + } + + // Split the multi-value and validate each parameter + $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1; + $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2; + $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 ); + + // Handle PARAM_ALL + $enumValues = $typeDef->getEnumValues( $name, $settings, $options ); + if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) && + count( $valuesList ) === 1 + ) { + $allValue = is_string( $settings[self::PARAM_ALL] ) + ? $settings[self::PARAM_ALL] + : self::ALL_DEFAULT_STRING; + if ( $valuesList[0] === $allValue ) { + return $enumValues; + } + } + + // Avoid checking useHighLimits() unless it's actually necessary + $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options ) + ? $limit2 + : $limit1; + if ( count( $valuesList ) > $sizeLimit ) { + throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [ + 'limit' => $sizeLimit + ] ); + } + + $options['values-list'] = $valuesList; + $validValues = []; + $invalidValues = []; + foreach ( $valuesList as $v ) { + try { + $validValues[] = $typeDef->validate( $name, $v, $settings, $options ); + } catch ( ValidationException $ex ) { + if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) { + throw $ex; + } + $invalidValues[] = $v; + } + } + if ( $invalidValues ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [ + 'values' => $invalidValues, + ] ), + $options + ); + } + + // Throw out duplicates if requested + if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) { + $validValues = array_values( array_unique( $validValues ) ); + } + + return $validValues; + } + + /** + * Split a multi-valued parameter string, like explode() + * + * Note that, unlike explode(), this will return an empty array when given + * an empty string. + * + * @param string $value + * @param int $limit + * @return string[] + */ + public static function explodeMultiValue( $value, $limit ) { + if ( $value === '' || $value === "\x1f" ) { + return []; + } + + if ( substr( $value, 0, 1 ) === "\x1f" ) { + $sep = "\x1f"; + $value = substr( $value, 1 ); + } else { + $sep = '|'; + } + + return explode( $sep, $value, $limit ); + } + +} diff --git a/includes/libs/ParamValidator/README.md b/includes/libs/ParamValidator/README.md new file mode 100644 index 0000000000..dd992a408a --- /dev/null +++ b/includes/libs/ParamValidator/README.md @@ -0,0 +1,58 @@ +Wikimedia API Parameter Validator +================================= + +This library implements a system for processing and validating parameters to an +API from data like that in PHP's `$_GET`, `$_POST`, and `$_FILES` arrays, based +on a declarative definition of available parameters. + +Usage +----- + +
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef\IntegerDef;
+use Wikimedia\ParamValidator\SimpleCallbacks as ParamValidatorCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+$validator = new ParamValidator(
+	new ParamValidatorCallbacks( $_POST + $_GET, $_FILES ),
+	$serviceContainer->getObjectFactory()
+);
+
+try {
+	$intValue = $validator->getValue( 'intParam', [
+			ParamValidator::PARAM_TYPE => 'integer',
+			ParamValidator::PARAM_DEFAULT => 0,
+			IntegerDef::PARAM_MIN => 0,
+			IntegerDef::PARAM_MAX => 5,
+	] );
+} catch ( ValidationException $ex ) {
+	$error = lookupI18nMessage( 'param-validator-error-' . $ex->getFailureCode() );
+	echo "Validation error: $error\n";
+}
+
+ +I18n +---- + +This library is designed to generate output in a manner suited to use with an +i18n system. To that end, errors and such are indicated by means of "codes" +consisting of ASCII lowercase letters, digits, and hyphen (and always beginning +with a letter). + +Additional details about each error, such as the allowed range for an integer +value, are similarly returned by means of associative arrays with keys being +similar "code" strings and values being strings, integers, or arrays of strings +that are intended to be formatted as a list (e.g. joined with commas). The +details for any particular "message" will also always have the same keys in the +same order to facilitate use with i18n systems using positional rather than +named parameters. + +For possible codes and their parameters, see the documentation of the relevant +`PARAM_*` constants and TypeDef classes. + +Running tests +------------- + + composer install --prefer-dist + composer test diff --git a/includes/libs/ParamValidator/SimpleCallbacks.php b/includes/libs/ParamValidator/SimpleCallbacks.php new file mode 100644 index 0000000000..77dab92619 --- /dev/null +++ b/includes/libs/ParamValidator/SimpleCallbacks.php @@ -0,0 +1,79 @@ +params = $params; + $this->files = $files; + } + + public function hasParam( $name, array $options ) { + return isset( $this->params[$name] ); + } + + public function getValue( $name, $default, array $options ) { + return $this->params[$name] ?? $default; + } + + public function hasUpload( $name, array $options ) { + return isset( $this->files[$name] ); + } + + public function getUploadedFile( $name, array $options ) { + $file = $this->files[$name] ?? null; + if ( $file && !$file instanceof UploadedFile ) { + $file = new UploadedFile( $file ); + $this->files[$name] = $file; + } + return $file; + } + + public function recordCondition( ValidationException $condition, array $options ) { + $this->conditions[] = $condition; + } + + /** + * Fetch any recorded conditions + * @return array[] + */ + public function getRecordedConditions() { + return $this->conditions; + } + + /** + * Clear any recorded conditions + */ + public function clearRecordedConditions() { + $this->conditions = []; + } + + public function useHighLimits( array $options ) { + return !empty( $options['useHighLimits'] ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef.php b/includes/libs/ParamValidator/TypeDef.php new file mode 100644 index 0000000000..0d54addc58 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef.php @@ -0,0 +1,148 @@ +callbacks = $callbacks; + } + + /** + * Get the value from the request + * + * @note Only override this if you need to use something other than + * $this->callbacks->getValue() to fetch the value. Reformatting from a + * string should typically be done by self::validate(). + * @note Handling of ParamValidator::PARAM_DEFAULT should be left to ParamValidator, + * as should PARAM_REQUIRED and the like. + * + * @param string $name Parameter name being fetched. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return null|mixed Return null if the value wasn't present, otherwise a + * value to be passed to self::validate(). + */ + public function getValue( $name, array $settings, array $options ) { + return $this->callbacks->getValue( $name, null, $options ); + } + + /** + * Validate the value + * + * When ParamValidator is processing a multi-valued parameter, this will be + * called once for each of the supplied values. Which may mean zero calls. + * + * When getValue() returned null, this will not be called. + * + * @param string $name Parameter name being validated. + * @param mixed $value Value to validate, from getValue(). + * @param array $settings Parameter settings array. + * @param array $options Options array. Note the following values that may be set + * by ParamValidator: + * - values-list: (string[]) If defined, values of a multi-valued parameter are being processed + * (and this array holds the full set of values). + * @return mixed Validated value + * @throws ValidationException if the value is invalid + */ + abstract public function validate( $name, $value, array $settings, array $options ); + + /** + * Normalize a settings array + * @param array $settings + * @return array + */ + public function normalizeSettings( array $settings ) { + return $settings; + } + + /** + * Get the values for enum-like parameters + * + * This is primarily intended for documentation and implementation of + * PARAM_ALL; it is the responsibility of the TypeDef to ensure that validate() + * accepts the values returned here. + * + * @param string $name Parameter name being validated. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return array|null All possible enumerated values, or null if this is + * not an enumeration. + */ + public function getEnumValues( $name, array $settings, array $options ) { + return null; + } + + /** + * Convert a value to a string representation. + * + * This is intended as the inverse of getValue() and validate(): this + * should accept anything returned by those methods or expected to be used + * as PARAM_DEFAULT, and if the string from this method is passed in as client + * input or PARAM_DEFAULT it should give equivalent output from validate(). + * + * @param string $name Parameter name being converted. + * @param mixed $value Parameter value being converted. Do not pass null. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return string|null Return null if there is no representation of $value + * reasonably satisfying the description given. + */ + public function stringifyValue( $name, $value, array $settings, array $options ) { + return (string)$value; + } + + /** + * "Describe" a settings array + * + * This is intended to format data about a settings array using this type + * in a way that would be useful for automatically generated documentation + * or a machine-readable interface specification. + * + * Keys in the description array should follow the same guidelines as the + * code described for ValidationException. + * + * By default, each value in the description array is a single string, + * integer, or array. When `$options['compact']` is supplied, each value is + * instead an array of such and related values may be combined. For example, + * a non-compact description for an integer type might include + * `[ 'default' => 0, 'min' => 0, 'max' => 5 ]`, while in compact mode it might + * instead report `[ 'default' => [ 'value' => 0 ], 'minmax' => [ 'min' => 0, 'max' => 5 ] ]` + * to facilitate auto-generated documentation turning that 'minmax' into + * "Value must be between 0 and 5" rather than disconnected statements + * "Value must be >= 0" and "Value must be <= 5". + * + * @param string $name Parameter name being described. + * @param array $settings Parameter settings array. + * @param array $options Options array. Defined options for this base class are: + * - 'compact': (bool) Enable compact mode, as described above. + * @return array + */ + public function describeSettings( $name, array $settings, array $options ) { + $compact = !empty( $options['compact'] ); + + $ret = []; + + if ( isset( $settings[ParamValidator::PARAM_DEFAULT] ) ) { + $value = $this->stringifyValue( + $name, $settings[ParamValidator::PARAM_DEFAULT], $settings, $options + ); + $ret['default'] = $compact ? [ 'value' => $value ] : $value; + } + + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/BooleanDef.php b/includes/libs/ParamValidator/TypeDef/BooleanDef.php new file mode 100644 index 0000000000..f77c930499 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/BooleanDef.php @@ -0,0 +1,45 @@ + self::$TRUEVALS, + 'falsevals' => array_merge( self::$FALSEVALS, [ 'the empty string' ] ), + ] ); + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + return $value ? self::$TRUEVALS[0] : self::$FALSEVALS[0]; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/EnumDef.php b/includes/libs/ParamValidator/TypeDef/EnumDef.php new file mode 100644 index 0000000000..0f4f6908e5 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/EnumDef.php @@ -0,0 +1,88 @@ +getEnumValues( $name, $settings, $options ); + + if ( in_array( $value, $values, true ) ) { + // Set a warning if a deprecated parameter value has been passed + if ( isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'deprecated-value', [ + 'flag' => $settings[self::PARAM_DEPRECATED_VALUES][$value], + ] ), + $options + ); + } + + return $value; + } + + if ( !isset( $options['values-list'] ) && + count( ParamValidator::explodeMultiValue( $value, 2 ) ) > 1 + ) { + throw new ValidationException( $name, $value, $settings, 'notmulti', [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badvalue', [] ); + } + } + + public function getEnumValues( $name, array $settings, array $options ) { + return $settings[ParamValidator::PARAM_TYPE]; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !is_array( $value ) ) { + return parent::stringifyValue( $name, $value, $settings, $options ); + } + + foreach ( $value as $v ) { + if ( strpos( $v, '|' ) !== false ) { + return "\x1f" . implode( "\x1f", $value ); + } + } + return implode( '|', $value ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/FloatDef.php b/includes/libs/ParamValidator/TypeDef/FloatDef.php new file mode 100644 index 0000000000..0a204b3a88 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/FloatDef.php @@ -0,0 +1,72 @@ + '.', + // PHP's number formatting currently uses only the first byte from 'decimal_point'. + // See upstream bug https://bugs.php.net/bug.php?id=78113 + $localeData['decimal_point'][0] => '.', + ] ); + } + return $value; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Ensure sufficient precision for round-tripping. PHP_FLOAT_DIG was added in PHP 7.2. + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + return $this->fixLocaleWeirdness( sprintf( "%.{$digits}g", $value ) ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/IntegerDef.php b/includes/libs/ParamValidator/TypeDef/IntegerDef.php new file mode 100644 index 0000000000..556301b898 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/IntegerDef.php @@ -0,0 +1,171 @@ + $max ) { + if ( $max2 !== null && $this->callbacks->useHighLimits( $options ) ) { + if ( $ret > $max2 ) { + $err = 'abovehighmaximum'; + $ret = $max2; + } + } else { + $err = 'abovemaximum'; + $ret = $max; + } + } + if ( $err !== null ) { + $ex = new ValidationException( $name, $value, $settings, $err, [ + 'min' => $min === null ? '' : $min, + 'max' => $max === null ? '' : $max, + 'max2' => $max2 === null ? '' : $max2, + ] ); + if ( empty( $settings[self::PARAM_IGNORE_RANGE] ) ) { + throw $ex; + } + $this->callbacks->recordCondition( $ex, $options ); + } + + return $ret; + } + + public function normalizeSettings( array $settings ) { + if ( !isset( $settings[self::PARAM_MAX] ) ) { + unset( $settings[self::PARAM_MAX2] ); + } + + if ( isset( $settings[self::PARAM_MAX2] ) && isset( $settings[self::PARAM_MAX] ) && + $settings[self::PARAM_MAX2] < $settings[self::PARAM_MAX] + ) { + $settings[self::PARAM_MAX2] = $settings[self::PARAM_MAX]; + } + + return parent::normalizeSettings( $settings ); + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + + $min = $settings[self::PARAM_MIN] ?? ''; + $max = $settings[self::PARAM_MAX] ?? ''; + $max2 = $settings[self::PARAM_MAX2] ?? ''; + if ( $max === '' || $max2 !== '' && $max2 <= $max ) { + $max2 = ''; + } + + if ( empty( $options['compact'] ) ) { + if ( $min !== '' ) { + $info['min'] = $min; + } + if ( $max !== '' ) { + $info['max'] = $max; + } + if ( $max2 !== '' ) { + $info['max2'] = $max2; + } + } else { + $key = ''; + if ( $min !== '' ) { + $key = 'min'; + } + if ( $max2 !== '' ) { + $key .= 'max2'; + } elseif ( $max !== '' ) { + $key .= 'max'; + } + if ( $key !== '' ) { + $info[$key] = [ 'min' => $min, 'max' => $max, 'max2' => $max2 ]; + } + } + + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/LimitDef.php b/includes/libs/ParamValidator/TypeDef/LimitDef.php new file mode 100644 index 0000000000..99780c4a0d --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/LimitDef.php @@ -0,0 +1,44 @@ +callbacks->useHighLimits( $options ) + ? $settings[self::PARAM_MAX2] ?? $settings[self::PARAM_MAX] ?? PHP_INT_MAX + : $settings[self::PARAM_MAX] ?? PHP_INT_MAX; + } + return $value; + } + + return parent::validate( $name, $value, $settings, $options ); + } + + public function normalizeSettings( array $settings ) { + $settings += [ + self::PARAM_MIN => 0, + ]; + + return parent::normalizeSettings( $settings ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/PasswordDef.php b/includes/libs/ParamValidator/TypeDef/PasswordDef.php new file mode 100644 index 0000000000..289db54869 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/PasswordDef.php @@ -0,0 +1,22 @@ +callbacks->hasParam( $name, $options ); + } + + public function validate( $name, $value, array $settings, array $options ) { + return (bool)$value; + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + unset( $info['default'] ); + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/StringDef.php b/includes/libs/ParamValidator/TypeDef/StringDef.php new file mode 100644 index 0000000000..0ed310b50f --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/StringDef.php @@ -0,0 +1,88 @@ +allowEmptyWhenRequired = !empty( $options['allowEmptyWhenRequired'] ); + } + + public function validate( $name, $value, array $settings, array $options ) { + if ( !$this->allowEmptyWhenRequired && $value === '' && + !empty( $settings[ParamValidator::PARAM_REQUIRED] ) + ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + + if ( isset( $settings[self::PARAM_MAX_BYTES] ) + && strlen( $value ) > $settings[self::PARAM_MAX_BYTES] + ) { + throw new ValidationException( $name, $value, $settings, 'maxbytes', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + if ( isset( $settings[self::PARAM_MAX_CHARS] ) + && mb_strlen( $value, 'UTF-8' ) > $settings[self::PARAM_MAX_CHARS] + ) { + throw new ValidationException( $name, $value, $settings, 'maxchars', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + + return $value; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/TimestampDef.php b/includes/libs/ParamValidator/TypeDef/TimestampDef.php new file mode 100644 index 0000000000..5d0bf4e951 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/TimestampDef.php @@ -0,0 +1,100 @@ +defaultFormat = $options['defaultFormat'] ?? 'ConvertibleTimestamp'; + $this->stringifyFormat = $options['stringifyFormat'] ?? TS_ISO_8601; + } + + public function validate( $name, $value, array $settings, array $options ) { + // Confusing synonyms for the current time accepted by ConvertibleTimestamp + if ( !$value ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unclearnowtimestamp', [] ), + $options + ); + $value = 'now'; + } + + try { + $timestamp = new ConvertibleTimestamp( $value === 'now' ? false : $value ); + } catch ( TimestampException $ex ) { + throw new ValidationException( $name, $value, $settings, 'badtimestamp', [], $ex ); + } + + $format = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat; + switch ( $format ) { + case 'ConvertibleTimestamp': + return $timestamp; + + case 'DateTime': + // Eew, no getter. + return $timestamp->timestamp; + + default: + return $timestamp->getTimestamp( $format ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !$value instanceof ConvertibleTimestamp ) { + $value = new ConvertibleTimestamp( $value ); + } + return $value->getTimestamp( $this->stringifyFormat ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/UploadDef.php b/includes/libs/ParamValidator/TypeDef/UploadDef.php new file mode 100644 index 0000000000..b436a6dc54 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/UploadDef.php @@ -0,0 +1,116 @@ +callbacks->getUploadedFile( $name, $options ); + + if ( $ret && $ret->getError() === UPLOAD_ERR_NO_FILE && + !$this->callbacks->hasParam( $name, $options ) + ) { + // This seems to be that the client explicitly specified "no file" for the field + // instead of just omitting the field completely. DWTM. + $ret = null; + } elseif ( !$ret && $this->callbacks->hasParam( $name, $options ) ) { + // The client didn't format their upload properly so it came in as an ordinary + // field. Convert it to an error. + $ret = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => -42, // PHP's UPLOAD_ERR_* are all positive numbers. + 'size' => 0, + ] ); + } + + return $ret; + } + + /** + * Fetch the value of PHP's upload_max_filesize ini setting + * + * This method exists so it can be mocked by unit tests that can't + * affect ini_get() directly. + * + * @codeCoverageIgnore + * @return string|false + */ + protected function getIniSize() { + return ini_get( 'upload_max_filesize' ); + } + + public function validate( $name, $value, array $settings, array $options ) { + static $codemap = [ + -42 => 'notupload', // Local from getValue() + UPLOAD_ERR_FORM_SIZE => 'formsize', + UPLOAD_ERR_PARTIAL => 'partial', + UPLOAD_ERR_NO_FILE => 'nofile', + UPLOAD_ERR_NO_TMP_DIR => 'notmpdir', + UPLOAD_ERR_CANT_WRITE => 'cantwrite', + UPLOAD_ERR_EXTENSION => 'phpext', + ]; + + if ( !$value instanceof UploadedFileInterface ) { + // Err? + throw new ValidationException( $name, $value, $settings, 'badupload', [] ); + } + + $err = $value->getError(); + if ( $err === UPLOAD_ERR_OK ) { + return $value; + } elseif ( $err === UPLOAD_ERR_INI_SIZE ) { + static $prefixes = [ + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024 ** 1, + ]; + $size = $this->getIniSize(); + $last = strtolower( substr( $size, -1 ) ); + $size = intval( $size, 10 ) * ( $prefixes[$last] ?? 1 ); + throw new ValidationException( $name, $value, $settings, 'badupload-inisize', [ + 'size' => $size, + ] ); + } elseif ( isset( $codemap[$err] ) ) { + throw new ValidationException( $name, $value, $settings, 'badupload-' . $codemap[$err], [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badupload-unknown', [ + 'code' => $err, + ] ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Not going to happen. + return null; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFile.php b/includes/libs/ParamValidator/Util/UploadedFile.php new file mode 100644 index 0000000000..2be9119d25 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFile.php @@ -0,0 +1,141 @@ +data = $data; + $this->fromUpload = $fromUpload; + } + + /** + * Throw if there was an error + * @throws RuntimeException + */ + private function checkError() { + switch ( $this->data['error'] ) { + case UPLOAD_ERR_OK: + break; + + case UPLOAD_ERR_INI_SIZE: + throw new RuntimeException( 'Upload exceeded maximum size' ); + + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException( 'Upload exceeded form-specified maximum size' ); + + case UPLOAD_ERR_PARTIAL: + throw new RuntimeException( 'File was only partially uploaded' ); + + case UPLOAD_ERR_NO_FILE: + throw new RuntimeException( 'No file was uploaded' ); + + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException( 'PHP has no temporary folder for storing uploaded files' ); + + case UPLOAD_ERR_CANT_WRITE: + throw new RuntimeException( 'PHP was unable to save the uploaded file' ); + + case UPLOAD_ERR_EXTENSION: + throw new RuntimeException( 'A PHP extension stopped the file upload' ); + + default: + throw new RuntimeException( 'Unknown upload error code ' . $this->data['error'] ); + } + + if ( $this->moved ) { + throw new RuntimeException( 'File has already been moved' ); + } + if ( !isset( $this->data['tmp_name'] ) || !file_exists( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Uploaded file is missing' ); + } + } + + public function getStream() { + if ( $this->stream ) { + return $this->stream; + } + + $this->checkError(); + $this->stream = new UploadedFileStream( $this->data['tmp_name'] ); + return $this->stream; + } + + public function moveTo( $targetPath ) { + $this->checkError(); + + if ( $this->fromUpload && !is_uploaded_file( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Specified file is not an uploaded file' ); + } + + // TODO remove the function_exists check once we drop HHVM support + if ( function_exists( 'error_clear_last' ) ) { + error_clear_last(); + } + $ret = AtEase::quietCall( + $this->fromUpload ? 'move_uploaded_file' : 'rename', + $this->data['tmp_name'], + $targetPath + ); + if ( $ret === false ) { + $err = error_get_last(); + throw new RuntimeException( "Move failed: " . ( $err['message'] ?? 'Unknown error' ) ); + } + + $this->moved = true; + if ( $this->stream ) { + $this->stream->close(); + $this->stream = null; + } + } + + public function getSize() { + return $this->data['size'] ?? null; + } + + public function getError() { + return $this->data['error'] ?? UPLOAD_ERR_NO_FILE; + } + + public function getClientFilename() { + $ret = $this->data['name'] ?? null; + return $ret === '' ? null : $ret; + } + + public function getClientMediaType() { + $ret = $this->data['type'] ?? null; + return $ret === '' ? null : $ret; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFileStream.php b/includes/libs/ParamValidator/Util/UploadedFileStream.php new file mode 100644 index 0000000000..17eaaf4a01 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFileStream.php @@ -0,0 +1,168 @@ +fp = self::quietCall( 'fopen', [ $filename, 'r' ], false, 'Failed to open file' ); + } + + /** + * Check if the stream is open + * @throws RuntimeException if closed + */ + private function checkOpen() { + if ( !$this->fp ) { + throw new RuntimeException( 'Stream is not open' ); + } + } + + public function __destruct() { + $this->close(); + } + + public function __toString() { + try { + $this->seek( 0 ); + return $this->getContents(); + } catch ( Exception $ex ) { + // Not allowed to throw + return ''; + } catch ( Throwable $ex ) { + // Not allowed to throw + return ''; + } + } + + public function close() { + if ( $this->fp ) { + // Spec doesn't care about close errors. + AtEase::quietCall( 'fclose', $this->fp ); + $this->fp = null; + } + } + + public function detach() { + $ret = $this->fp; + $this->fp = null; + return $ret; + } + + public function getSize() { + if ( $this->size === false ) { + $this->size = null; + + if ( $this->fp ) { + // Spec doesn't care about errors here. + $stat = AtEase::quietCall( 'fstat', $this->fp ); + $this->size = $stat['size'] ?? null; + } + } + + return $this->size; + } + + public function tell() { + $this->checkOpen(); + return self::quietCall( 'ftell', [ $this->fp ], -1, 'Cannot determine stream position' ); + } + + public function eof() { + // Spec doesn't care about errors here. + return !$this->fp || AtEase::quietCall( 'feof', $this->fp ); + } + + public function isSeekable() { + return (bool)$this->fp; + } + + public function seek( $offset, $whence = SEEK_SET ) { + $this->checkOpen(); + self::quietCall( 'fseek', [ $this->fp, $offset, $whence ], -1, 'Seek failed' ); + } + + public function rewind() { + $this->seek( 0 ); + } + + public function isWritable() { + return false; + } + + public function write( $string ) { + $this->checkOpen(); + throw new RuntimeException( 'Stream is read-only' ); + } + + public function isReadable() { + return (bool)$this->fp; + } + + public function read( $length ) { + $this->checkOpen(); + return self::quietCall( 'fread', [ $this->fp, $length ], false, 'Read failed' ); + } + + public function getContents() { + $this->checkOpen(); + return self::quietCall( 'stream_get_contents', [ $this->fp ], false, 'Read failed' ); + } + + public function getMetadata( $key = null ) { + $this->checkOpen(); + $ret = self::quietCall( 'stream_get_meta_data', [ $this->fp ], false, 'Metadata fetch failed' ); + if ( $key !== null ) { + $ret = $ret[$key] ?? null; + } + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/ValidationException.php b/includes/libs/ParamValidator/ValidationException.php new file mode 100644 index 0000000000..c8d995e0b9 --- /dev/null +++ b/includes/libs/ParamValidator/ValidationException.php @@ -0,0 +1,128 @@ +paramName = $name; + $this->paramValue = $value; + $this->settings = $settings; + $this->failureCode = $code; + $this->failureData = $data; + } + + /** + * Make a simple English message for the exception + * @param string $name + * @param string $code + * @param array $data + * @return string + */ + private static function formatMessage( $name, $code, $data ) { + $ret = "Validation of `$name` failed: $code"; + foreach ( $data as $k => $v ) { + if ( is_array( $v ) ) { + $v = implode( ', ', $v ); + } + $ret .= "; $k => $v"; + } + return $ret; + } + + /** + * Fetch the parameter name that failed validation + * @return string + */ + public function getParamName() { + return $this->paramName; + } + + /** + * Fetch the parameter value that failed validation + * @return mixed + */ + public function getParamValue() { + return $this->paramValue; + } + + /** + * Fetch the settings array that failed validation + * @return array + */ + public function getSettings() { + return $this->settings; + } + + /** + * Fetch the validation failure code + * + * A validation failure code is a reasonably short string matching the regex + * `/^[a-z][a-z0-9-]*$/`. + * + * Users are encouraged to use this with a suitable i18n mechanism rather + * than relying on the limited English text returned by getMessage(). + * + * @return string + */ + public function getFailureCode() { + return $this->failureCode; + } + + /** + * Fetch the validation failure data + * + * This returns additional data relevant to the particular failure code. + * + * Keys in the array are short ASCII strings. Values are strings or + * integers, or arrays of strings intended to be displayed as a + * comma-separated list. For any particular code the same keys are always + * returned in the same order, making it safe to use array_values() and + * access them positionally if that is desired. + * + * For example, the data for a hypothetical "integer-out-of-range" code + * might have data `[ 'min' => 0, 'max' => 100 ]` indicating the range of + * allowed values. + * + * @return (string|int|string[])[] + */ + public function getFailureData() { + return $this->failureData; + } + +} diff --git a/includes/libs/StatusValue.php b/includes/libs/StatusValue.php index 16cb1ed1d3..71a0e348dd 100644 --- a/includes/libs/StatusValue.php +++ b/includes/libs/StatusValue.php @@ -107,7 +107,7 @@ class StatusValue { } else { $errorsOnlyStatusValue->errors[] = $item; } - }; + } return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ]; } diff --git a/includes/libs/eventrelayer/EventRelayerKafka.php b/includes/libs/eventrelayer/EventRelayerKafka.php index 999eb43935..1224b4bba2 100644 --- a/includes/libs/eventrelayer/EventRelayerKafka.php +++ b/includes/libs/eventrelayer/EventRelayerKafka.php @@ -1,4 +1,5 @@ fatal( 'backend-fail-contenttype', $params['dst'] ); @@ -360,7 +360,7 @@ class SwiftFileBackend extends FileBackendStore { $method = __METHOD__; $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) { list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response']; - if ( $rcode === 201 ) { + if ( $rcode === 201 || $rcode === 202 ) { // good } elseif ( $rcode === 412 ) { $status->fatal( 'backend-fail-contenttype', $params['dst'] ); @@ -1707,9 +1707,7 @@ class SwiftFileBackend extends FileBackendStore { if ( $rcode >= 200 && $rcode <= 299 ) { // OK $this->authCreds = [ 'auth_token' => $rhdrs['x-auth-token'], - 'storage_url' => ( $this->swiftStorageUrl !== null ) - ? $this->swiftStorageUrl - : $rhdrs['x-storage-url'] + 'storage_url' => $this->swiftStorageUrl ?? $rhdrs['x-storage-url'] ]; $this->srvCache->set( $cacheKey, $this->authCreds, ceil( $this->authTTL / 2 ) ); diff --git a/includes/libs/filebackend/filejournal/FileJournal.php b/includes/libs/filebackend/filejournal/FileJournal.php index 999594b85e..dc007a0cec 100644 --- a/includes/libs/filebackend/filejournal/FileJournal.php +++ b/includes/libs/filebackend/filejournal/FileJournal.php @@ -26,6 +26,8 @@ * @ingroup FileJournal */ +use Wikimedia\Timestamp\ConvertibleTimestamp; + /** * @brief Class for handling file operation journaling. * @@ -37,7 +39,6 @@ abstract class FileJournal { /** @var string */ protected $backend; - /** @var int */ protected $ttlDays; @@ -63,7 +64,7 @@ abstract class FileJournal { $class = $config['class']; $jrn = new $class( $config ); if ( !$jrn instanceof self ) { - throw new InvalidArgumentException( "Class given is not an instance of FileJournal." ); + throw new InvalidArgumentException( "$class is not an instance of " . __CLASS__ ); } $jrn->backend = $backend; @@ -82,7 +83,9 @@ abstract class FileJournal { } $s = Wikimedia\base_convert( sha1( $s ), 16, 36, 31 ); - return substr( Wikimedia\base_convert( wfTimestamp( TS_MW ), 10, 36, 9 ) . $s, 0, 31 ); + $timestamp = ConvertibleTimestamp::convert( TS_MW, time() ); + + return substr( Wikimedia\base_convert( $timestamp, 10, 36, 9 ) . $s, 0, 31 ); } /** diff --git a/includes/libs/http/MultiHttpClient.php b/includes/libs/http/MultiHttpClient.php new file mode 100644 index 0000000000..a6135aeb7a --- /dev/null +++ b/includes/libs/http/MultiHttpClient.php @@ -0,0 +1,609 @@ + (uses RFC 3986) + * - headers :
+ * - body : source to get the HTTP request body from; + * this can simply be a string (always), a resource for + * PUT requests, and a field/value array for POST request; + * array bodies are encoded as multipart/form-data and strings + * use application/x-www-form-urlencoded (headers sent automatically) + * - stream : resource to stream the HTTP response body to + * - proxy : HTTP proxy to use + * - flags : map of boolean flags which supports: + * - relayResponseHeaders : write out header via header() + * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'. + * + * @since 1.23 + */ +class MultiHttpClient implements LoggerAwareInterface { + /** @var resource */ + protected $multiHandle = null; // curl_multi handle + /** @var string|null SSL certificates path */ + protected $caBundlePath; + /** @var float */ + protected $connTimeout = 10; + /** @var float */ + protected $reqTimeout = 300; + /** @var bool */ + protected $usePipelining = false; + /** @var int */ + protected $maxConnsPerHost = 50; + /** @var string|null proxy */ + protected $proxy; + /** @var string */ + protected $userAgent = 'wikimedia/multi-http-client v1.0'; + /** @var LoggerInterface */ + protected $logger; + + // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect + // timeouts are periodically polled instead of being accurately respected. + // The select timeout is set to the minimum timeout multiplied by this factor. + const TIMEOUT_ACCURACY_FACTOR = 0.1; + + /** + * @param array $options + * - connTimeout : default connection timeout (seconds) + * - reqTimeout : default request timeout (seconds) + * - proxy : HTTP proxy to use + * - usePipelining : whether to use HTTP pipelining if possible (for all hosts) + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * - userAgent : The User-Agent header value to send + * - logger : a \Psr\Log\LoggerInterface instance for debug logging + * - caBundlePath : path to specific Certificate Authority bundle (if any) + * @throws Exception + */ + public function __construct( array $options ) { + if ( isset( $options['caBundlePath'] ) ) { + $this->caBundlePath = $options['caBundlePath']; + if ( !file_exists( $this->caBundlePath ) ) { + throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath ); + } + } + static $opts = [ + 'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost', + 'proxy', 'userAgent', 'logger' + ]; + foreach ( $opts as $key ) { + if ( isset( $options[$key] ) ) { + $this->$key = $options[$key]; + } + } + if ( $this->logger === null ) { + $this->logger = new NullLogger; + } + } + + /** + * Execute an HTTP(S) request + * + * This method returns a response map of: + * - code : HTTP response code or 0 if there was a serious error + * - reason : HTTP response reason (empty if there was a serious error) + * - headers :
+ * - body : HTTP response body or resource (if "stream" was set) + * - error : Any error string + * The map also stores integer-indexed copies of these values. This lets callers do: + * @code + * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req ); + * @endcode + * @param array $req HTTP request array + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * @return array Response array for request + */ + public function run( array $req, array $opts = [] ) { + return $this->runMulti( [ $req ], $opts )[0]['response']; + } + + /** + * Execute a set of HTTP(S) requests. + * + * If curl is available, requests will be made concurrently. + * Otherwise, they will be made serially. + * + * The maps are returned by this method with the 'response' field set to a map of: + * - code : HTTP response code or 0 if there was a serious error + * - reason : HTTP response reason (empty if there was a serious error) + * - headers :
+ * - body : HTTP response body or resource (if "stream" was set) + * - error : Any error string + * The map also stores integer-indexed copies of these values. This lets callers do: + * @code + * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response']; + * @endcode + * All headers in the 'headers' field are normalized to use lower case names. + * This is true for the request headers and the response headers. Integer-indexed + * method/URL entries will also be changed to use the corresponding string keys. + * + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * - usePipelining : whether to use HTTP pipelining if possible + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * @return array $reqs With response array populated for each + * @throws Exception + */ + public function runMulti( array $reqs, array $opts = [] ) { + $this->normalizeRequests( $reqs ); + if ( $this->isCurlEnabled() ) { + return $this->runMultiCurl( $reqs, $opts ); + } else { + return $this->runMultiHttp( $reqs, $opts ); + } + } + + /** + * Determines if the curl extension is available + * + * @return bool true if curl is available, false otherwise. + */ + protected function isCurlEnabled() { + return extension_loaded( 'curl' ); + } + + /** + * Execute a set of HTTP(S) requests concurrently + * + * @see MultiHttpClient::runMulti() + * + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * - usePipelining : whether to use HTTP pipelining if possible + * - maxConnsPerHost : maximum number of concurrent connections (per host) + * @return array $reqs With response array populated for each + * @throws Exception + */ + private function runMultiCurl( array $reqs, array $opts = [] ) { + $chm = $this->getCurlMulti(); + + $selectTimeout = $this->getSelectTimeout( $opts ); + + // Add all of the required cURL handles... + $handles = []; + foreach ( $reqs as $index => &$req ) { + $handles[$index] = $this->getCurlHandle( $req, $opts ); + if ( count( $reqs ) > 1 ) { + // https://github.com/guzzle/guzzle/issues/349 + curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true ); + } + } + unset( $req ); // don't assign over this by accident + + $indexes = array_keys( $reqs ); + if ( isset( $opts['usePipelining'] ) ) { + curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] ); + } + if ( isset( $opts['maxConnsPerHost'] ) ) { + // Keep these sockets around as they may be needed later in the request + curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] ); + } + + // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS) + $batches = array_chunk( $indexes, $this->maxConnsPerHost ); + $infos = []; + + foreach ( $batches as $batch ) { + // Attach all cURL handles for this batch + foreach ( $batch as $index ) { + curl_multi_add_handle( $chm, $handles[$index] ); + } + // Execute the cURL handles concurrently... + $active = null; // handles still being processed + do { + // Do any available work... + do { + $mrc = curl_multi_exec( $chm, $active ); + $info = curl_multi_info_read( $chm ); + if ( $info !== false ) { + $infos[(int)$info['handle']] = $info; + } + } while ( $mrc == CURLM_CALL_MULTI_PERFORM ); + // Wait (if possible) for available work... + if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) { + // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html + usleep( 5000 ); // 5ms + } + } while ( $active > 0 && $mrc == CURLM_OK ); + } + + // Remove all of the added cURL handles and check for errors... + foreach ( $reqs as $index => &$req ) { + $ch = $handles[$index]; + curl_multi_remove_handle( $chm, $ch ); + + if ( isset( $infos[(int)$ch] ) ) { + $info = $infos[(int)$ch]; + $errno = $info['result']; + if ( $errno !== 0 ) { + $req['response']['error'] = "(curl error: $errno)"; + if ( function_exists( 'curl_strerror' ) ) { + $req['response']['error'] .= " " . curl_strerror( $errno ); + } + $this->logger->warning( "Error fetching URL \"{$req['url']}\": " . + $req['response']['error'] ); + } + } else { + $req['response']['error'] = "(curl error: no status set)"; + } + + // For convenience with the list() operator + $req['response'][0] = $req['response']['code']; + $req['response'][1] = $req['response']['reason']; + $req['response'][2] = $req['response']['headers']; + $req['response'][3] = $req['response']['body']; + $req['response'][4] = $req['response']['error']; + curl_close( $ch ); + // Close any string wrapper file handles + if ( isset( $req['_closeHandle'] ) ) { + fclose( $req['_closeHandle'] ); + unset( $req['_closeHandle'] ); + } + } + unset( $req ); // don't assign over this by accident + + // Restore the default settings + curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining ); + curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); + + return $reqs; + } + + /** + * @param array &$req HTTP request map + * @param array $opts + * - connTimeout : default connection timeout + * - reqTimeout : default request timeout + * @return resource + * @throws Exception + */ + protected function getCurlHandle( array &$req, array $opts = [] ) { + $ch = curl_init(); + + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS, + ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 ); + curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy ); + curl_setopt( $ch, CURLOPT_TIMEOUT_MS, + ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 ); + curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 ); + curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 ); + curl_setopt( $ch, CURLOPT_HEADER, 0 ); + if ( !is_null( $this->caBundlePath ) ) { + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true ); + curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath ); + } + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); + + $url = $req['url']; + $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); + if ( $query != '' ) { + $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; + } + curl_setopt( $ch, CURLOPT_URL, $url ); + + curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] ); + if ( $req['method'] === 'HEAD' ) { + curl_setopt( $ch, CURLOPT_NOBODY, 1 ); + } + + if ( $req['method'] === 'PUT' ) { + curl_setopt( $ch, CURLOPT_PUT, 1 ); + if ( is_resource( $req['body'] ) ) { + curl_setopt( $ch, CURLOPT_INFILE, $req['body'] ); + if ( isset( $req['headers']['content-length'] ) ) { + curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] ); + } elseif ( isset( $req['headers']['transfer-encoding'] ) && + $req['headers']['transfer-encoding'] === 'chunks' + ) { + curl_setopt( $ch, CURLOPT_UPLOAD, true ); + } else { + throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." ); + } + } elseif ( $req['body'] !== '' ) { + $fp = fopen( "php://temp", "wb+" ); + fwrite( $fp, $req['body'], strlen( $req['body'] ) ); + rewind( $fp ); + curl_setopt( $ch, CURLOPT_INFILE, $fp ); + curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) ); + $req['_closeHandle'] = $fp; // remember to close this later + } else { + curl_setopt( $ch, CURLOPT_INFILESIZE, 0 ); + } + curl_setopt( $ch, CURLOPT_READFUNCTION, + function ( $ch, $fd, $length ) { + $data = fread( $fd, $length ); + $len = strlen( $data ); + return $data; + } + ); + } elseif ( $req['method'] === 'POST' ) { + curl_setopt( $ch, CURLOPT_POST, 1 ); + // Don't interpret POST parameters starting with '@' as file uploads, because this + // makes it impossible to POST plain values starting with '@' (and causes security + // issues potentially exposing the contents of local files). + curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] ); + } else { + if ( is_resource( $req['body'] ) || $req['body'] !== '' ) { + throw new Exception( "HTTP body specified for a non PUT/POST request." ); + } + $req['headers']['content-length'] = 0; + } + + if ( !isset( $req['headers']['user-agent'] ) ) { + $req['headers']['user-agent'] = $this->userAgent; + } + + $headers = []; + foreach ( $req['headers'] as $name => $value ) { + if ( strpos( $name, ': ' ) ) { + throw new Exception( "Headers cannot have ':' in the name." ); + } + $headers[] = $name . ': ' . trim( $value ); + } + curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); + + curl_setopt( $ch, CURLOPT_HEADERFUNCTION, + function ( $ch, $header ) use ( &$req ) { + if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) { + header( $header ); + } + $length = strlen( $header ); + $matches = []; + if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) { + $req['response']['code'] = (int)$matches[2]; + $req['response']['reason'] = trim( $matches[3] ); + return $length; + } + if ( strpos( $header, ":" ) === false ) { + return $length; + } + list( $name, $value ) = explode( ":", $header, 2 ); + $name = strtolower( $name ); + $value = trim( $value ); + if ( isset( $req['response']['headers'][$name] ) ) { + $req['response']['headers'][$name] .= ', ' . $value; + } else { + $req['response']['headers'][$name] = $value; + } + return $length; + } + ); + + if ( isset( $req['stream'] ) ) { + // Don't just use CURLOPT_FILE as that might give: + // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE* + // The callback here handles both normal files and php://temp handles. + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, + function ( $ch, $data ) use ( &$req ) { + return fwrite( $req['stream'], $data ); + } + ); + } else { + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, + function ( $ch, $data ) use ( &$req ) { + $req['response']['body'] .= $data; + return strlen( $data ); + } + ); + } + + return $ch; + } + + /** + * @return resource + * @throws Exception + */ + protected function getCurlMulti() { + if ( !$this->multiHandle ) { + if ( !function_exists( 'curl_multi_init' ) ) { + throw new Exception( "PHP cURL function curl_multi_init missing. " . + "Check https://www.mediawiki.org/wiki/Manual:CURL" ); + } + $cmh = curl_multi_init(); + curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining ); + curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost ); + $this->multiHandle = $cmh; + } + return $this->multiHandle; + } + + /** + * Execute a set of HTTP(S) requests sequentially. + * + * @see MultiHttpClient::runMulti() + * @todo Remove dependency on MediaWikiServices: use a separate HTTP client + * library or copy code from PhpHttpRequest + * @param array $reqs Map of HTTP request arrays + * @param array $opts + * - connTimeout : connection timeout per request (seconds) + * - reqTimeout : post-connection timeout per request (seconds) + * @return array $reqs With response array populated for each + * @throws Exception + */ + private function runMultiHttp( array $reqs, array $opts = [] ) { + $httpOptions = [ + 'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout, + 'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout, + 'logger' => $this->logger, + 'caInfo' => $this->caBundlePath, + ]; + foreach ( $reqs as &$req ) { + $reqOptions = $httpOptions + [ + 'method' => $req['method'], + 'proxy' => $req['proxy'] ?? $this->proxy, + 'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent, + 'postData' => $req['body'], + ]; + + $url = $req['url']; + $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 ); + if ( $query != '' ) { + $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query"; + } + + $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( + $url, $reqOptions ); + $sv = $httpRequest->execute()->getStatusValue(); + + $respHeaders = array_map( + function ( $v ) { + return implode( ', ', $v ); + }, + $httpRequest->getResponseHeaders() ); + + $req['response'] = [ + 'code' => $httpRequest->getStatus(), + 'reason' => '', + 'headers' => $respHeaders, + 'body' => $httpRequest->getContent(), + 'error' => '', + ]; + + if ( !$sv->isOk() ) { + $svErrors = $sv->getErrors(); + if ( isset( $svErrors[0] ) ) { + $req['response']['error'] = $svErrors[0]['message']; + + // param values vary per failure type (ex. unknown host vs unknown page) + if ( isset( $svErrors[0]['params'][0] ) ) { + if ( is_numeric( $svErrors[0]['params'][0] ) ) { + if ( isset( $svErrors[0]['params'][1] ) ) { + $req['response']['reason'] = $svErrors[0]['params'][1]; + } + } else { + $req['response']['reason'] = $svErrors[0]['params'][0]; + } + } + } + } + + $req['response'][0] = $req['response']['code']; + $req['response'][1] = $req['response']['reason']; + $req['response'][2] = $req['response']['headers']; + $req['response'][3] = $req['response']['body']; + $req['response'][4] = $req['response']['error']; + } + + return $reqs; + } + + /** + * Normalize request information + * + * @param array $reqs the requests to normalize + */ + private function normalizeRequests( array &$reqs ) { + foreach ( $reqs as &$req ) { + $req['response'] = [ + 'code' => 0, + 'reason' => '', + 'headers' => [], + 'body' => '', + 'error' => '' + ]; + if ( isset( $req[0] ) ) { + $req['method'] = $req[0]; // short-form + unset( $req[0] ); + } + if ( isset( $req[1] ) ) { + $req['url'] = $req[1]; // short-form + unset( $req[1] ); + } + if ( !isset( $req['method'] ) ) { + throw new Exception( "Request has no 'method' field set." ); + } elseif ( !isset( $req['url'] ) ) { + throw new Exception( "Request has no 'url' field set." ); + } + $this->logger->debug( "{$req['method']}: {$req['url']}" ); + $req['query'] = $req['query'] ?? []; + $headers = []; // normalized headers + if ( isset( $req['headers'] ) ) { + foreach ( $req['headers'] as $name => $value ) { + $headers[strtolower( $name )] = $value; + } + } + $req['headers'] = $headers; + if ( !isset( $req['body'] ) ) { + $req['body'] = ''; + $req['headers']['content-length'] = 0; + } + $req['flags'] = $req['flags'] ?? []; + } + } + + /** + * Get a suitable select timeout for the given options. + * + * @param array $opts + * @return float + */ + private function getSelectTimeout( $opts ) { + $connTimeout = $opts['connTimeout'] ?? $this->connTimeout; + $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout; + $timeouts = array_filter( [ $connTimeout, $reqTimeout ] ); + if ( count( $timeouts ) === 0 ) { + return 1; + } + + $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR; + // Minimum 10us for sanity + if ( $selectTimeout < 10e-6 ) { + $selectTimeout = 10e-6; + } + return $selectTimeout; + } + + /** + * Register a logger + * + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + function __destruct() { + if ( $this->multiHandle ) { + curl_multi_close( $this->multiHandle ); + } + } +} diff --git a/includes/libs/lockmanager/NullLockManager.php b/includes/libs/lockmanager/NullLockManager.php index b83462c798..339a7ee43a 100644 --- a/includes/libs/lockmanager/NullLockManager.php +++ b/includes/libs/lockmanager/NullLockManager.php @@ -22,15 +22,38 @@ */ /** - * Simple version of LockManager that does nothing + * Simple version of LockManager that only does lock reference counting * @since 1.19 */ class NullLockManager extends LockManager { protected function doLock( array $paths, $type ) { + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + ++$this->locksHeld[$path][$type]; + } else { + $this->locksHeld[$path][$type] = 1; + } + } + return StatusValue::newGood(); } protected function doUnlock( array $paths, $type ) { - return StatusValue::newGood(); + $status = StatusValue::newGood(); + + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + if ( --$this->locksHeld[$path][$type] <= 0 ) { + unset( $this->locksHeld[$path][$type] ); + if ( !$this->locksHeld[$path] ) { + unset( $this->locksHeld[$path] ); // clean up + } + } + } else { + $status->warning( 'lockmanager-notlocked', $path ); + } + } + + return $status; } } diff --git a/includes/libs/lockmanager/QuorumLockManager.php b/includes/libs/lockmanager/QuorumLockManager.php index 1ef4642a84..950b283670 100644 --- a/includes/libs/lockmanager/QuorumLockManager.php +++ b/includes/libs/lockmanager/QuorumLockManager.php @@ -35,15 +35,7 @@ abstract class QuorumLockManager extends LockManager { /** @var array Map of degraded buckets */ protected $degradedBuckets = []; // (bucket index => UNIX timestamp) - final protected function doLock( array $paths, $type ) { - return $this->doLockByType( [ $type => $paths ] ); - } - - final protected function doUnlock( array $paths, $type ) { - return $this->doUnlockByType( [ $type => $paths ] ); - } - - protected function doLockByType( array $pathsByType ) { + final protected function doLockByType( array $pathsByType ) { $status = StatusValue::newGood(); $pathsToLock = []; // (bucket => type => paths) @@ -278,4 +270,12 @@ abstract class QuorumLockManager extends LockManager { * @return StatusValue */ abstract protected function releaseAllLocks(); + + final protected function doLock( array $paths, $type ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + final protected function doUnlock( array $paths, $type ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } } diff --git a/includes/libs/mime/MimeAnalyzer.php b/includes/libs/mime/MimeAnalyzer.php index a326df2bc2..f493769968 100644 --- a/includes/libs/mime/MimeAnalyzer.php +++ b/includes/libs/mime/MimeAnalyzer.php @@ -755,7 +755,9 @@ EOT; /** * look for XML formats (XHTML and SVG) */ + Wikimedia\suppressWarnings(); $xml = new XmlTypeCheck( $file ); + Wikimedia\restoreWarnings(); if ( $xml->wellFormed ) { $xmlTypes = $this->xmlTypes; return $xmlTypes[$xml->getRootElement()] ?? 'application/xml'; @@ -849,7 +851,7 @@ EOT; $callback = $this->guessCallback; if ( $callback ) { $callback( $this, $head, $tail, $file, $mime /* by reference */ ); - }; + } return $mime; } diff --git a/includes/libs/mime/XmlTypeCheck.php b/includes/libs/mime/XmlTypeCheck.php index 0b523916cb..65cc54bdff 100644 --- a/includes/libs/mime/XmlTypeCheck.php +++ b/includes/libs/mime/XmlTypeCheck.php @@ -27,13 +27,13 @@ class XmlTypeCheck { /** - * Will be set to true or false to indicate whether the file is + * @var bool|null Will be set to true or false to indicate whether the file is * well-formed XML. Note that this doesn't check schema validity. */ public $wellFormed = null; /** - * Will be set to true if the optional element filter returned + * @var bool Will be set to true if the optional element filter returned * a match at some point. */ public $filterMatch = false; @@ -46,30 +46,30 @@ class XmlTypeCheck { public $filterMatchType = false; /** - * Name of the document's root element, including any namespace + * @var string Name of the document's root element, including any namespace * as an expanded URL. */ public $rootElement = ''; /** - * A stack of strings containing the data of each xml element as it's processed. Append - * data to the top string of the stack, then pop off the string and process it when the + * @var string[] A stack of strings containing the data of each xml element as it's processed. + * Append data to the top string of the stack, then pop off the string and process it when the * element is closed. */ protected $elementData = []; /** - * A stack of element names and attributes, as we process them. + * @var array A stack of element names and attributes, as we process them. */ protected $elementDataContext = []; /** - * Current depth of the data stack. + * @var int Current depth of the data stack. */ protected $stackDepth = 0; /** - * Additional parsing options + * @var array Additional parsing options */ private $parserOptions = [ 'processing_instruction_handler' => null, @@ -308,7 +308,7 @@ class XmlTypeCheck { /** * @param string $name - * @param string $attribs + * @param array $attribs */ private function elementOpen( $name, $attribs ) { $this->elementDataContext[] = [ $name, $attribs ]; diff --git a/includes/libs/objectcache/APCBagOStuff.php b/includes/libs/objectcache/APCBagOStuff.php index 9a5a433c66..465fe820e0 100644 --- a/includes/libs/objectcache/APCBagOStuff.php +++ b/includes/libs/objectcache/APCBagOStuff.php @@ -34,18 +34,28 @@ * @ingroup Cache */ class APCBagOStuff extends BagOStuff { + /** @var bool Whether to trust the APC implementation to serialization */ + private $nativeSerialize; + /** * @var string String to append to each APC key. This may be changed * whenever the handling of values is changed, to prevent existing code * from encountering older values which it cannot handle. */ - const KEY_SUFFIX = ':3'; + const KEY_SUFFIX = ':4'; + + public function __construct( array $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; + parent::__construct( $params ); + // The extension serializer is still buggy, unlike "php" and "igbinary" + $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' ); + } protected function doGet( $key, $flags = 0, &$casToken = null ) { $casToken = null; $blob = apc_fetch( $key . self::KEY_SUFFIX ); - $value = $this->unserialize( $blob ); + $value = $this->nativeSerialize ? $blob : $this->unserialize( $blob ); if ( $value !== false ) { $casToken = $blob; // don't bother hashing this } @@ -53,10 +63,10 @@ class APCBagOStuff extends BagOStuff { return $value; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { apc_store( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); @@ -66,12 +76,12 @@ class APCBagOStuff extends BagOStuff { public function add( $key, $value, $exptime = 0, $flags = 0 ) { return apc_add( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { apc_delete( $key . self::KEY_SUFFIX ); return true; @@ -84,12 +94,4 @@ class APCBagOStuff extends BagOStuff { public function decr( $key, $value = 1 ) { return apc_dec( $key . self::KEY_SUFFIX, $value ); } - - protected function serialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : serialize( $value ); - } - - protected function unserialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); - } } diff --git a/includes/libs/objectcache/APCUBagOStuff.php b/includes/libs/objectcache/APCUBagOStuff.php index ed4eb35cd7..b14ac7c4df 100644 --- a/includes/libs/objectcache/APCUBagOStuff.php +++ b/includes/libs/objectcache/APCUBagOStuff.php @@ -34,18 +34,28 @@ * @ingroup Cache */ class APCUBagOStuff extends BagOStuff { + /** @var bool Whether to trust the APC implementation to serialization */ + private $nativeSerialize; + /** * @var string String to append to each APC key. This may be changed * whenever the handling of values is changed, to prevent existing code * from encountering older values which it cannot handle. */ - const KEY_SUFFIX = ':3'; + const KEY_SUFFIX = ':4'; + + public function __construct( array $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; + parent::__construct( $params ); + // The extension serializer is still buggy, unlike "php" and "igbinary" + $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' ); + } protected function doGet( $key, $flags = 0, &$casToken = null ) { $casToken = null; $blob = apcu_fetch( $key . self::KEY_SUFFIX ); - $value = $this->unserialize( $blob ); + $value = $this->nativeSerialize ? $blob : $this->unserialize( $blob ); if ( $value !== false ) { $casToken = $blob; // don't bother hashing this } @@ -53,10 +63,10 @@ class APCUBagOStuff extends BagOStuff { return $value; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { return apcu_store( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); } @@ -64,23 +74,19 @@ class APCUBagOStuff extends BagOStuff { public function add( $key, $value, $exptime = 0, $flags = 0 ) { return apcu_add( $key . self::KEY_SUFFIX, - $this->serialize( $value ), + $this->nativeSerialize ? $value : $this->serialize( $value ), $exptime ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { apcu_delete( $key . self::KEY_SUFFIX ); return true; } public function incr( $key, $value = 1 ) { - /** - * @todo When we only support php 7 or higher remove this hack - * - * https://github.com/krakjoe/apcu/issues/166 - */ + // https://github.com/krakjoe/apcu/issues/166 if ( apcu_exists( $key . self::KEY_SUFFIX ) ) { return apcu_inc( $key . self::KEY_SUFFIX, $value ); } else { @@ -89,23 +95,11 @@ class APCUBagOStuff extends BagOStuff { } public function decr( $key, $value = 1 ) { - /** - * @todo When we only support php 7 or higher remove this hack - * - * https://github.com/krakjoe/apcu/issues/166 - */ + // https://github.com/krakjoe/apcu/issues/166 if ( apcu_exists( $key . self::KEY_SUFFIX ) ) { return apcu_dec( $key . self::KEY_SUFFIX, $value ); } else { return false; } } - - protected function serialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : serialize( $value ); - } - - protected function unserialize( $value ) { - return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); - } } diff --git a/includes/libs/objectcache/BagOStuff.php b/includes/libs/objectcache/BagOStuff.php index 0dd7b57c6d..7759947ee0 100644 --- a/includes/libs/objectcache/BagOStuff.php +++ b/includes/libs/objectcache/BagOStuff.php @@ -50,8 +50,14 @@ use Wikimedia\WaitConditionLoop; * For any given instance, methods like lock(), unlock(), merge(), and set() with WRITE_SYNC * should semantically operate over its entire access scope; any nodes/threads in that scope * should serialize appropriately when using them. Likewise, a call to get() with READ_LATEST - * from one node in its access scope should reflect the prior changes of any other node its access - * scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC. + * from one node in its access scope should reflect the prior changes of any other node its + * access scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC. + * + * Subclasses should override the default "segmentationSize" field with an appropriate value. + * The value should not be larger than what the storage backend (by default) supports. It also + * should be roughly informed by common performance bottlenecks (e.g. values over a certain size + * having poor scalability). The same goes for the "segmentedValueMaxSize" member, which limits + * the maximum size and chunk count (indirectly) of values. * * @ingroup Cache */ @@ -68,6 +74,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { protected $asyncHandler; /** @var int Seconds */ protected $syncTimeout; + /** @var int Bytes; chunk size of segmented cache values */ + protected $segmentationSize; + /** @var int Bytes; maximum total size of a segmented cache value */ + protected $segmentedValueMaxSize; /** @var bool */ private $debugMode = false; @@ -93,6 +103,11 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { /** Bitfield constants for set()/merge() */ const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache + const WRITE_ALLOW_SEGMENTS = 16; // Allow partitioning of the value if it is large + const WRITE_PRUNE_SEGMENTS = 32; // Delete all partition segments of the value + + /** @var string Component to use for key construction of blob segment keys */ + const SEGMENT_COMPONENT = 'segment'; /** * $params include: @@ -103,6 +118,12 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * - reportDupes: Whether to emit warning log messages for all keys that were * requested more than once (requires an asyncHandler). * - syncTimeout: How long to wait with WRITE_SYNC in seconds. + * - segmentationSize: The chunk size, in bytes, of segmented values. The value should + * not exceed the maximum size of values in the storage backend, as configured by + * the site administrator. + * - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values. + * This should be configured to a reasonable size give the site traffic and the + * amount of I/O between application and cache servers that the network can handle. * @param array $params */ public function __construct( array $params = [] ) { @@ -119,6 +140,8 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { } $this->syncTimeout = $params['syncTimeout'] ?? 3; + $this->segmentationSize = $params['segmentationSize'] ?? 8388608; // 8MiB + $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67108864; // 64MiB } /** @@ -180,7 +203,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { public function get( $key, $flags = 0 ) { $this->trackDuplicateKeys( $key ); - return $this->doGet( $key, $flags ); + return $this->resolveSegments( $key, $this->doGet( $key, $flags ) ); } /** @@ -233,7 +256,103 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param int $flags Bitfield of BagOStuff::WRITE_* constants * @return bool Success */ - abstract public function set( $key, $value, $exptime = 0, $flags = 0 ); + public function set( $key, $value, $exptime = 0, $flags = 0 ) { + if ( + ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS || + is_infinite( $this->segmentationSize ) + ) { + return $this->doSet( $key, $value, $exptime, $flags ); + } + + $serialized = $this->serialize( $value ); + $segmentSize = $this->getSegmentationSize(); + $maxTotalSize = $this->getSegmentedValueMaxSize(); + + $size = strlen( $serialized ); + if ( $size <= $segmentSize ) { + // Since the work of serializing it was already done, just use it inline + return $this->doSet( + $key, + SerializedValueContainer::newUnified( $serialized ), + $exptime, + $flags + ); + } elseif ( $size > $maxTotalSize ) { + $this->setLastError( "Key $key exceeded $maxTotalSize bytes." ); + + return false; + } + + $chunksByKey = []; + $segmentHashes = []; + $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 ); + for ( $i = 0; $i < $count; ++$i ) { + $segment = substr( $serialized, $i * $segmentSize, $segmentSize ); + $hash = sha1( $segment ); + $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash ); + $chunksByKey[$chunkKey] = $segment; + $segmentHashes[] = $hash; + } + + $ok = $this->setMulti( $chunksByKey, $exptime, $flags ); + if ( $ok ) { + // Only when all segments are stored should the main key be changed + $ok = $this->doSet( + $key, + SerializedValueContainer::newSegmented( $segmentHashes ), + $exptime, + $flags + ); + } + + return $ok; + } + + /** + * Set an item + * + * @param string $key + * @param mixed $value + * @param int $exptime Either an interval in seconds or a unix timestamp for expiry + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + * @return bool Success + */ + abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 ); + + /** + * Delete an item + * + * For large values written using WRITE_ALLOW_SEGMENTS, this only deletes the main + * segment list key unless WRITE_PRUNE_SEGMENTS is in the flags. While deleting the segment + * list key has the effect of functionally deleting the key, it leaves unused blobs in cache. + * + * @param string $key + * @return bool True if the item was deleted or not found, false on failure + * @param int $flags Bitfield of BagOStuff::WRITE_* constants + */ + public function delete( $key, $flags = 0 ) { + if ( ( $flags & self::WRITE_PRUNE_SEGMENTS ) != self::WRITE_PRUNE_SEGMENTS ) { + return $this->doDelete( $key, $flags ); + } + + $mainValue = $this->doGet( $key, self::READ_LATEST ); + if ( !$this->doDelete( $key, $flags ) ) { + return false; + } + + if ( !SerializedValueContainer::isSegmented( $mainValue ) ) { + return true; // no segments to delete + } + + $orderedKeys = array_map( + function ( $segmentHash ) use ( $key ) { + return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash ); + }, + $mainValue->{SerializedValueContainer::SEGMENTED_HASHES} + ); + + return $this->deleteMulti( $orderedKeys, $flags ); + } /** * Delete an item @@ -242,7 +361,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return bool True if the item was deleted or not found, false on failure * @param int $flags Bitfield of BagOStuff::WRITE_* constants */ - abstract public function delete( $key, $flags = 0 ); + abstract protected function doDelete( $key, $flags = 0 ); /** * Insert an item if it does not already exist @@ -291,7 +410,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { $casToken = null; // passed by reference // Get the old value and CAS token from cache $this->clearLastError(); - $currentValue = $this->doGet( $key, self::READ_LATEST, $casToken ); + $currentValue = $this->resolveSegments( + $key, + $this->doGet( $key, self::READ_LATEST, $casToken ) + ); if ( $this->getLastError() ) { $this->logger->warning( __METHOD__ . ' failed due to I/O error on get() for {key}.', @@ -324,6 +446,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { return false; // IO error; don't spam retries } + } while ( !$success && --$attempts ); return $success; @@ -338,7 +461,6 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @param int $exptime Either an interval in seconds or a unix timestamp for expiry * @param int $flags Bitfield of BagOStuff::WRITE_* constants * @return bool Success - * @throws Exception */ protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { if ( !$this->lock( $key, 0 ) ) { @@ -368,28 +490,40 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * * If an expiry in the past is given then the key will immediately be expired * + * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the + * main segment list key. While lowering the TTL of the segment list key has the effect of + * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer. + * Raising the TTL of such keys is not effective, since the expiration of a single segment + * key effectively expires the entire value. + * * @param string $key - * @param int $expiry TTL or UNIX timestamp + * @param int $exptime TTL or UNIX timestamp * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) * @return bool Success Returns false on failure or if the item does not exist * @since 1.28 */ - public function changeTTL( $key, $expiry = 0, $flags = 0 ) { - $found = false; + public function changeTTL( $key, $exptime = 0, $flags = 0 ) { + $expiry = $this->convertToExpiry( $exptime ); + $delete = ( $expiry != 0 && $expiry < $this->getCurrentTime() ); - $ok = $this->merge( - $key, - function ( $cache, $ttl, $currentValue ) use ( &$found ) { - $found = ( $currentValue !== false ); + if ( !$this->lock( $key, 0 ) ) { + return false; + } + // Use doGet() to avoid having to trigger resolveSegments() + $blob = $this->doGet( $key, self::READ_LATEST ); + if ( $blob ) { + if ( $delete ) { + $ok = $this->doDelete( $key, $flags ); + } else { + $ok = $this->doSet( $key, $blob, $exptime, $flags ); + } + } else { + $ok = false; + } - return $currentValue; // nothing is written if this is false - }, - $expiry, - 1, // 1 attempt - $flags - ); + $this->unlock( $key ); - return ( $ok && $found ); + return $ok; } /** @@ -456,10 +590,14 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return bool Success */ public function unlock( $key ) { - if ( isset( $this->locks[$key] ) && --$this->locks[$key]['depth'] <= 0 ) { + if ( !isset( $this->locks[$key] ) ) { + return false; + } + + if ( --$this->locks[$key]['depth'] <= 0 ) { unset( $this->locks[$key] ); - $ok = $this->delete( "{$key}:lock" ); + $ok = $this->doDelete( "{$key}:lock" ); if ( !$ok ) { $this->logger->warning( __METHOD__ . ' failed to release lock for {key}.', @@ -533,9 +671,25 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return array */ public function getMulti( array $keys, $flags = 0 ) { + $valuesBykey = $this->doGetMulti( $keys, $flags ); + foreach ( $valuesBykey as $key => $value ) { + // Resolve one blob at a time (avoids too much I/O at once) + $valuesBykey[$key] = $this->resolveSegments( $key, $value ); + } + + return $valuesBykey; + } + + /** + * Get an associative array containing the item for each of the keys that have items. + * @param string[] $keys List of keys + * @param int $flags Bitfield; supports READ_LATEST [optional] + * @return array + */ + protected function doGetMulti( array $keys, $flags = 0 ) { $res = []; foreach ( $keys as $key ) { - $val = $this->get( $key, $flags ); + $val = $this->doGet( $key, $flags ); if ( $val !== false ) { $res[$key] = $val; } @@ -546,6 +700,9 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { /** * Batch insertion/replace + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * * @param mixed[] $data Map of (key => value) * @param int $exptime Either an interval in seconds or a unix timestamp for expiry * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33) @@ -553,11 +710,13 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @since 1.24 */ public function setMulti( array $data, $exptime = 0, $flags = 0 ) { + if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) { + throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' ); + } + $res = true; foreach ( $data as $key => $value ) { - if ( !$this->set( $key, $value, $exptime, $flags ) ) { - $res = false; - } + $res = $this->doSet( $key, $value, $exptime, $flags ) && $res; } return $res; @@ -565,6 +724,9 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { /** * Batch deletion + * + * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O + * * @param string[] $keys List of keys * @param int $flags Bitfield of BagOStuff::WRITE_* constants * @return bool Success @@ -573,7 +735,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { public function deleteMulti( array $keys, $flags = 0 ) { $res = true; foreach ( $keys as $key ) { - $res = $this->delete( $key, $flags ) && $res; + $res = $this->doDelete( $key, $flags ) && $res; } return $res; @@ -624,6 +786,43 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { return $newValue; } + /** + * Get and reassemble the chunks of blob at the given key + * + * @param string $key + * @param mixed $mainValue + * @return string|null|bool The combined string, false if missing, null on error + */ + protected function resolveSegments( $key, $mainValue ) { + if ( SerializedValueContainer::isUnified( $mainValue ) ) { + return $this->unserialize( $mainValue->{SerializedValueContainer::UNIFIED_DATA} ); + } + + if ( SerializedValueContainer::isSegmented( $mainValue ) ) { + $orderedKeys = array_map( + function ( $segmentHash ) use ( $key ) { + return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash ); + }, + $mainValue->{SerializedValueContainer::SEGMENTED_HASHES} + ); + + $segmentsByKey = $this->doGetMulti( $orderedKeys ); + + $parts = []; + foreach ( $orderedKeys as $segmentKey ) { + if ( isset( $segmentsByKey[$segmentKey] ) ) { + $parts[] = $segmentsByKey[$segmentKey]; + } else { + return false; // missing segment + } + } + + return $this->unserialize( implode( '', $parts ) ); + } + + return $mainValue; + } + /** * Get the "last error" registered; clearLastError() should be called manually * @return int ERR_* constant for the "last error" registry @@ -732,7 +931,15 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { * @return bool */ protected function isInteger( $value ) { - return ( is_int( $value ) || ctype_digit( $value ) ); + if ( is_int( $value ) ) { + return true; + } elseif ( !is_string( $value ) ) { + return false; + } + + $integer = (int)$value; + + return ( $value === (string)$integer ); } /** @@ -784,6 +991,22 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { return $this->attrMap[$flag] ?? self::QOS_UNKNOWN; } + /** + * @return int|float The chunk size, in bytes, of segmented objects (INF for no limit) + * @since 1.34 + */ + public function getSegmentationSize() { + return $this->segmentationSize; + } + + /** + * @return int|float Maximum total segmented object size in bytes (INF for no limit) + * @since 1.34 + */ + public function getSegmentedValueMaxSize() { + return $this->segmentedValueMaxSize; + } + /** * Merge the flag maps of one or more BagOStuff objects into a "lowest common denominator" map * @@ -806,18 +1029,38 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface { } /** + * @internal For testing only * @return float UNIX timestamp * @codeCoverageIgnore */ - protected function getCurrentTime() { + public function getCurrentTime() { return $this->wallClockOverride ?: microtime( true ); } /** - * @param float|null &$time Mock UNIX timestamp for testing + * @internal For testing only + * @param float|null &$time Mock UNIX timestamp * @codeCoverageIgnore */ public function setMockTime( &$time ) { $this->wallClockOverride =& $time; } + + /** + * @param mixed $value + * @return string|int String/integer representation + * @note Special handling is usually needed for integers so incr()/decr() work + */ + protected function serialize( $value ) { + return is_int( $value ) ? $value : serialize( $value ); + } + + /** + * @param string|int $value + * @return mixed Original value or false on error + * @note Special handling is usually needed for integers so incr()/decr() work + */ + protected function unserialize( $value ) { + return $this->isInteger( $value ) ? (int)$value : unserialize( $value ); + } } diff --git a/includes/libs/objectcache/EmptyBagOStuff.php b/includes/libs/objectcache/EmptyBagOStuff.php index ffe3a4c53e..575bc58746 100644 --- a/includes/libs/objectcache/EmptyBagOStuff.php +++ b/includes/libs/objectcache/EmptyBagOStuff.php @@ -33,15 +33,15 @@ class EmptyBagOStuff extends BagOStuff { return false; } - public function add( $key, $value, $exp = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exp = 0, $flags = 0 ) { return true; } - public function set( $key, $value, $exp = 0, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { return true; } - public function delete( $key, $flags = 0 ) { + public function add( $key, $value, $exptime = 0, $flags = 0 ) { return true; } @@ -49,6 +49,10 @@ class EmptyBagOStuff extends BagOStuff { return false; } + public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) { + return false; // faster + } + public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) { return true; // faster } diff --git a/includes/libs/objectcache/HashBagOStuff.php b/includes/libs/objectcache/HashBagOStuff.php index d24f40854e..016bdfe36e 100644 --- a/includes/libs/objectcache/HashBagOStuff.php +++ b/includes/libs/objectcache/HashBagOStuff.php @@ -49,6 +49,7 @@ class HashBagOStuff extends BagOStuff { * - maxKeys : only allow this many keys (using oldest-first eviction) */ function __construct( $params = [] ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; parent::__construct( $params ); $this->token = microtime( true ) . ':' . mt_rand(); @@ -75,7 +76,7 @@ class HashBagOStuff extends BagOStuff { return $this->bag[$key][self::KEY_VAL]; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { // Refresh key position for maxCacheKeys eviction unset( $this->bag[$key] ); $this->bag[$key] = [ @@ -94,14 +95,14 @@ class HashBagOStuff extends BagOStuff { } public function add( $key, $value, $exptime = 0, $flags = 0 ) { - if ( $this->get( $key ) === false ) { - return $this->set( $key, $value, $exptime, $flags ); + if ( $this->hasKey( $key ) && !$this->expire( $key ) ) { + return false; // key already set } - return false; // key already set + return $this->doSet( $key, $value, $exptime, $flags ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { unset( $this->bag[$key] ); return true; @@ -136,7 +137,7 @@ class HashBagOStuff extends BagOStuff { return false; } - $this->delete( $key ); + $this->doDelete( $key ); return true; } diff --git a/includes/libs/objectcache/MemcachedBagOStuff.php b/includes/libs/objectcache/MemcachedBagOStuff.php index 3d6bd16129..cfbf2b3e80 100644 --- a/includes/libs/objectcache/MemcachedBagOStuff.php +++ b/includes/libs/objectcache/MemcachedBagOStuff.php @@ -26,14 +26,12 @@ * * @ingroup Cache */ -class MemcachedBagOStuff extends BagOStuff { - /** @var MemcachedClient|Memcached */ - protected $client; - +abstract class MemcachedBagOStuff extends BagOStuff { function __construct( array $params ) { parent::__construct( $params ); $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_BE; // unreliable + $this->segmentationSize = $params['maxPreferedKeySize'] ?? 917504; // < 1MiB } /** @@ -50,55 +48,6 @@ class MemcachedBagOStuff extends BagOStuff { ]; } - protected function doGet( $key, $flags = 0, &$casToken = null ) { - return $this->client->get( $this->validateKeyEncoding( $key ), $casToken ); - } - - public function set( $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->set( $this->validateKeyEncoding( $key ), $value, - $this->fixExpiry( $exptime ) ); - } - - protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->cas( $casToken, $this->validateKeyEncoding( $key ), - $value, $this->fixExpiry( $exptime ) ); - } - - public function delete( $key, $flags = 0 ) { - return $this->client->delete( $this->validateKeyEncoding( $key ) ); - } - - public function add( $key, $value, $exptime = 0, $flags = 0 ) { - return $this->client->add( $this->validateKeyEncoding( $key ), $value, - $this->fixExpiry( $exptime ) ); - } - - public function incr( $key, $value = 1 ) { - $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value ); - - return ( $n !== false && $n !== null ) ? $n : false; - } - - public function decr( $key, $value = 1 ) { - $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value ); - - return ( $n !== false && $n !== null ) ? $n : false; - } - - public function changeTTL( $key, $exptime = 0, $flags = 0 ) { - return $this->client->touch( $this->validateKeyEncoding( $key ), - $this->fixExpiry( $exptime ) ); - } - - /** - * Get the underlying client object. This is provided for debugging - * purposes. - * @return MemcachedClient|Memcached - */ - public function getClient() { - return $this->client; - } - /** * Construct a cache key. * diff --git a/includes/libs/objectcache/MemcachedClient.php b/includes/libs/objectcache/MemcachedClient.php index 937ca5546d..eecf7ec799 100644 --- a/includes/libs/objectcache/MemcachedClient.php +++ b/includes/libs/objectcache/MemcachedClient.php @@ -278,6 +278,23 @@ class MemcachedClient { } // }}} + + /** + * @param mixed $value + * @return string|integer + */ + public function serialize( $value ) { + return serialize( $value ); + } + + /** + * @param string $value + * @return mixed + */ + public function unserialize( $value ) { + return unserialize( $value ); + } + // {{{ add() /** @@ -503,7 +520,8 @@ class MemcachedClient { if ( $this->_debug ) { foreach ( $val as $k => $v ) { - $this->_debugprint( sprintf( "MemCache: sock %s got %s", serialize( $sock ), $k ) ); + $this->_debugprint( + sprintf( "MemCache: sock %s got %s", $this->serialize( $sock ), $k ) ); } } @@ -1018,7 +1036,7 @@ class MemcachedClient { * yet read "END"), these 2 calls would collide. */ if ( $flags & self::SERIALIZED ) { - $ret[$rkey] = unserialize( $ret[$rkey] ); + $ret[$rkey] = $this->unserialize( $ret[$rkey] ); } elseif ( $flags & self::INTVAL ) { $ret[$rkey] = intval( $ret[$rkey] ); } @@ -1072,7 +1090,7 @@ class MemcachedClient { if ( is_int( $val ) ) { $flags |= self::INTVAL; } elseif ( !is_scalar( $val ) ) { - $val = serialize( $val ); + $val = $this->serialize( $val ); $flags |= self::SERIALIZED; if ( $this->_debug ) { $this->_debugprint( sprintf( "client: serializing data as it is not scalar" ) ); diff --git a/includes/libs/objectcache/MemcachedPeclBagOStuff.php b/includes/libs/objectcache/MemcachedPeclBagOStuff.php index db9450387f..43cebd3255 100644 --- a/includes/libs/objectcache/MemcachedPeclBagOStuff.php +++ b/includes/libs/objectcache/MemcachedPeclBagOStuff.php @@ -27,6 +27,8 @@ * @ingroup Cache */ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { + /** @var Memcached */ + protected $client; /** * Available parameters are: @@ -93,24 +95,22 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { $this->client->setOption( Memcached::OPT_LIBKETAMA_COMPATIBLE, true ); // Set the serializer - switch ( $params['serializer'] ) { - case 'php': - $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP ); - break; - case 'igbinary': - if ( !Memcached::HAVE_IGBINARY ) { - throw new InvalidArgumentException( - __CLASS__ . ': the igbinary extension is not available ' . - 'but igbinary serialization was requested.' - ); - } - $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY ); - break; - default: + $ok = false; + if ( $params['serializer'] === 'php' ) { + $ok = $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP ); + } elseif ( $params['serializer'] === 'igbinary' ) { + if ( !Memcached::HAVE_IGBINARY ) { throw new InvalidArgumentException( - __CLASS__ . ': invalid value for serializer parameter' + __CLASS__ . ': the igbinary extension is not available ' . + 'but igbinary serialization was requested.' ); + } + $ok = $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY ); + } + if ( !$ok ) { + throw new InvalidArgumentException( __CLASS__ . ': invalid serializer parameter' ); } + $servers = []; foreach ( $params['servers'] as $host ) { if ( preg_match( '/^\[(.+)\]:(\d+)$/', $host, $m ) ) { @@ -138,9 +138,6 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { return $params; } - /** - * @suppress PhanTypeNonVarPassByRef - */ protected function doGet( $key, $flags = 0, &$casToken = null ) { $this->debug( "get($key)" ); if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0 @@ -160,9 +157,13 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { return $result; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "set($key)" ); - $result = parent::set( $key, $value, $exptime, $flags = 0 ); + $result = $this->client->set( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) { // "Not stored" is always used as the mcrouter response with AllAsyncRoute return true; @@ -172,12 +173,14 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "cas($key)" ); - return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime, $flags ) ); + $result = $this->client->cas( $casToken, $this->validateKeyEncoding( $key ), + $value, $this->fixExpiry( $exptime ) ); + return $this->checkResult( $key, $result ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { $this->debug( "delete($key)" ); - $result = parent::delete( $key ); + $result = $this->client->delete( $this->validateKeyEncoding( $key ) ); if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) { // "Not found" is counted as success in our interface return true; @@ -187,7 +190,12 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { public function add( $key, $value, $exptime = 0, $flags = 0 ) { $this->debug( "add($key)" ); - return $this->checkResult( $key, parent::add( $key, $value, $exptime ) ); + $result = $this->client->add( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + return $this->checkResult( $key, $result ); } public function incr( $key, $value = 1 ) { @@ -242,7 +250,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { return $result; } - public function getMulti( array $keys, $flags = 0 ) { + public function doGetMulti( array $keys, $flags = 0 ) { $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' ); foreach ( $keys as $key ) { $this->validateKeyEncoding( $key ); @@ -260,9 +268,55 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff { return $this->checkResult( false, $result ); } - public function changeTTL( $key, $expiry = 0, $flags = 0 ) { + public function deleteMulti( array $keys, $flags = 0 ) { + $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' ); + foreach ( $keys as $key ) { + $this->validateKeyEncoding( $key ); + } + $result = $this->client->deleteMulti( $keys ) ?: []; + $ok = true; + foreach ( $result as $code ) { + if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) { + // "Not found" is counted as success in our interface + $ok = false; + } + } + return $this->checkResult( false, $ok ); + } + + public function changeTTL( $key, $exptime = 0, $flags = 0 ) { $this->debug( "touch($key)" ); - $result = $this->client->touch( $key, $expiry ); + $result = $this->client->touch( $key, $exptime ); return $this->checkResult( $key, $result ); } + + protected function serialize( $value ) { + if ( is_int( $value ) ) { + return $value; + } + + $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER ); + if ( $serializer === Memcached::SERIALIZER_PHP ) { + return serialize( $value ); + } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) { + return igbinary_serialize( $value ); + } + + throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." ); + } + + protected function unserialize( $value ) { + if ( $this->isInteger( $value ) ) { + return (int)$value; + } + + $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER ); + if ( $serializer === Memcached::SERIALIZER_PHP ) { + return unserialize( $value ); + } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) { + return igbinary_unserialize( $value ); + } + + throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." ); + } } diff --git a/includes/libs/objectcache/MemcachedPhpBagOStuff.php b/includes/libs/objectcache/MemcachedPhpBagOStuff.php index 8f190c3b76..ea73cbaee7 100644 --- a/includes/libs/objectcache/MemcachedPhpBagOStuff.php +++ b/includes/libs/objectcache/MemcachedPhpBagOStuff.php @@ -27,6 +27,9 @@ * @ingroup Cache */ class MemcachedPhpBagOStuff extends MemcachedBagOStuff { + /** @var MemcachedClient */ + protected $client; + /** * Available parameters are: * - servers: The list of IP:port combinations holding the memcached servers. @@ -51,11 +54,73 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff { $this->client->set_debug( $debug ); } - public function getMulti( array $keys, $flags = 0 ) { + protected function doGet( $key, $flags = 0, &$casToken = null ) { + $casToken = null; + + return $this->client->get( $this->validateKeyEncoding( $key ), $casToken ); + } + + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->set( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + } + + protected function doDelete( $key, $flags = 0 ) { + return $this->client->delete( $this->validateKeyEncoding( $key ) ); + } + + public function add( $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->add( + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + } + + protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { + return $this->client->cas( + $casToken, + $this->validateKeyEncoding( $key ), + $value, + $this->fixExpiry( $exptime ) + ); + } + + public function incr( $key, $value = 1 ) { + $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value ); + + return ( $n !== false && $n !== null ) ? $n : false; + } + + public function decr( $key, $value = 1 ) { + $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value ); + + return ( $n !== false && $n !== null ) ? $n : false; + } + + public function changeTTL( $key, $exptime = 0, $flags = 0 ) { + return $this->client->touch( + $this->validateKeyEncoding( $key ), + $this->fixExpiry( $exptime ) + ); + } + + public function doGetMulti( array $keys, $flags = 0 ) { foreach ( $keys as $key ) { $this->validateKeyEncoding( $key ); } return $this->client->get_multi( $keys ); } + + protected function serialize( $value ) { + return is_int( $value ) ? $value : $this->client->serialize( $value ); + } + + protected function unserialize( $value ) { + return $this->isInteger( $value ) ? (int)$value : $this->client->unserialize( $value ); + } } diff --git a/includes/libs/objectcache/MultiWriteBagOStuff.php b/includes/libs/objectcache/MultiWriteBagOStuff.php index adb9bb8ae4..e8327344c7 100644 --- a/includes/libs/objectcache/MultiWriteBagOStuff.php +++ b/includes/libs/objectcache/MultiWriteBagOStuff.php @@ -130,12 +130,14 @@ class MultiWriteBagOStuff extends BagOStuff { $missIndexes, $this->asyncWrites, 'set', + // @TODO: consider using self::WRITE_ALLOW_SEGMENTS here? [ $key, $value, self::UPGRADE_TTL ] ); } return $value; } + public function set( $key, $value, $exptime = 0, $flags = 0 ) { return $this->doWrite( $this->cacheIndexes, @@ -205,6 +207,7 @@ class MultiWriteBagOStuff extends BagOStuff { // Only the first cache is locked return $this->caches[0]->unlock( $key ); } + /** * Delete objects expiring before a certain date. * @@ -289,13 +292,14 @@ class MultiWriteBagOStuff extends BagOStuff { public function clearLastError() { $this->caches[0]->clearLastError(); } + /** * Apply a write method to the backing caches specified by $indexes (in order) * * @param int[] $indexes List of backing cache indexes * @param bool $asyncWrites * @param string $method Method name of backing caches - * @param array[] $args Arguments to the method of backing caches + * @param array $args Arguments to the method of backing caches * @return bool */ protected function doWrite( $indexes, $asyncWrites, $method, array $args ) { @@ -353,4 +357,24 @@ class MultiWriteBagOStuff extends BagOStuff { protected function doGet( $key, $flags = 0, &$casToken = null ) { throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); } + + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function doDelete( $key, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function doGetMulti( array $keys, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function serialize( $value ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function unserialize( $value ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } } diff --git a/includes/libs/objectcache/RESTBagOStuff.php b/includes/libs/objectcache/RESTBagOStuff.php index 4d8ae596d9..2a126891a6 100644 --- a/includes/libs/objectcache/RESTBagOStuff.php +++ b/includes/libs/objectcache/RESTBagOStuff.php @@ -7,26 +7,42 @@ use Psr\Log\LoggerInterface; * * Uses URL of the form "baseURL/{KEY}" to store, fetch, and delete values. * - * E.g., when base URL is `/v1/sessions/`, then the store would do: + * E.g., when base URL is `/sessions/v1`, then the store would do: * - * `PUT /v1/sessions/12345758` + * `PUT /sessions/v1/12345758` * * and fetch would do: * - * `GET /v1/sessions/12345758` + * `GET /sessions/v1/12345758` * * delete would do: * - * `DELETE /v1/sessions/12345758` + * `DELETE /sessions/v1/12345758` * - * Configure with: + * Minimal generic configuration: * * @code * $wgObjectCaches['sessions'] = array( * 'class' => 'RESTBagOStuff', - * 'url' => 'http://localhost:7231/wikimedia.org/v1/sessions/' + * 'url' => 'http://localhost:7231/wikimedia.org/somepath/' * ); * @endcode + * + * Configuration for Kask (session storage): + * @code + * $wgObjectCaches['sessions'] = array( + * 'class' => 'RESTBagOStuff', + * 'url' => 'https://kaskhost:1234/sessions/v1/', + * 'httpParams' => [ + * 'readHeaders' => [], + * 'writeHeaders' => [ 'content-type' => 'application/octet-stream' ], + * 'deleteHeaders' => [], + * 'writeMethod' => 'POST', + * ], + * 'extendedErrorBodyFields' => [ 'type', 'title', 'detail', 'instance' ] + * ); + * $wgSessionCacheType = 'sessions'; + * @endcode */ class RESTBagOStuff extends BagOStuff { /** @@ -52,10 +68,22 @@ class RESTBagOStuff extends BagOStuff { */ private $url; + /** + * @var array http parameters: readHeaders, writeHeaders, deleteHeaders, writeMethod + */ + private $httpParams; + + /** + * @var array additional body fields to log on error, if possible + */ + private $extendedErrorBodyFields; + public function __construct( $params ) { + $params['segmentationSize'] = $params['segmentationSize'] ?? INF; if ( empty( $params['url'] ) ) { throw new InvalidArgumentException( 'URL parameter is required' ); } + if ( empty( $params['client'] ) ) { // Pass through some params to the HTTP client. $clientParams = [ @@ -71,10 +99,19 @@ class RESTBagOStuff extends BagOStuff { } else { $this->client = $params['client']; } + + $this->httpParams['writeMethod'] = $params['httpParams']['writeMethod'] ?? 'PUT'; + $this->httpParams['readHeaders'] = $params['httpParams']['readHeaders'] ?? []; + $this->httpParams['writeHeaders'] = $params['httpParams']['writeHeaders'] ?? []; + $this->httpParams['deleteHeaders'] = $params['httpParams']['deleteHeaders'] ?? []; + $this->extendedErrorBodyFields = $params['extendedErrorBodyFields'] ?? []; + // The parent constructor calls setLogger() which sets the logger in $this->client parent::__construct( $params ); + // Make sure URL ends with / $this->url = rtrim( $params['url'], '/' ) . '/'; + // Default config, R+W > N; no locks on reads though; writes go straight to state-machine $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_QC; } @@ -90,12 +127,13 @@ class RESTBagOStuff extends BagOStuff { $req = [ 'method' => 'GET', 'url' => $this->url . rawurlencode( $key ), + 'headers' => $this->httpParams['readHeaders'], ]; list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req ); if ( $rcode === 200 ) { if ( is_string( $rbody ) ) { - $value = unserialize( $rbody ); + $value = $this->decodeBody( $rbody ); /// @FIXME: use some kind of hash or UUID header as CAS token $casToken = ( $value !== false ) ? $rbody : null; @@ -104,24 +142,26 @@ class RESTBagOStuff extends BagOStuff { return false; } if ( $rcode === 0 || ( $rcode >= 400 && $rcode != 404 ) ) { - return $this->handleError( "Failed to fetch $key", $rcode, $rerr ); + return $this->handleError( "Failed to fetch $key", $rcode, $rerr, $rhdrs, $rbody ); } return false; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM) // @TODO: respect $exptime $req = [ - 'method' => 'PUT', + 'method' => $this->httpParams['writeMethod'], 'url' => $this->url . rawurlencode( $key ), - 'body' => serialize( $value ) + 'body' => $this->encodeBody( $value ), + 'headers' => $this->httpParams['writeHeaders'], ]; + list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req ); if ( $rcode === 200 || $rcode === 201 || $rcode === 204 ) { return true; } - return $this->handleError( "Failed to store $key", $rcode, $rerr ); + return $this->handleError( "Failed to store $key", $rcode, $rerr, $rhdrs, $rbody ); } public function add( $key, $value, $exptime = 0, $flags = 0 ) { @@ -133,17 +173,19 @@ class RESTBagOStuff extends BagOStuff { return false; // key already set } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM) $req = [ 'method' => 'DELETE', 'url' => $this->url . rawurlencode( $key ), + 'headers' => $this->httpParams['deleteHeaders'], ]; + list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req ); if ( in_array( $rcode, [ 200, 204, 205, 404, 410 ] ) ) { return true; } - return $this->handleError( "Failed to delete $key", $rcode, $rerr ); + return $this->handleError( "Failed to delete $key", $rcode, $rerr, $rhdrs, $rbody ); } public function incr( $key, $value = 1 ) { @@ -158,18 +200,65 @@ class RESTBagOStuff extends BagOStuff { return false; } + /** + * Processes the response body. + * + * @param string $body request body to process + * @return mixed|bool the processed body, or false on error + */ + private function decodeBody( $body ) { + $value = json_decode( $body, true ); + return ( json_last_error() === JSON_ERROR_NONE ) ? $value : false; + } + + /** + * Prepares the request body (the "value" portion of our key/value store) for transmission. + * + * @param string $body request body to prepare + * @return string the prepared body, or an empty string on error + * @throws LogicException + */ + private function encodeBody( $body ) { + $value = json_encode( $body ); + if ( $value === false ) { + throw new InvalidArgumentException( __METHOD__ . ": body could not be encoded." ); + } + return $value; + } + /** * Handle storage error * @param string $msg Error message * @param int $rcode Error code from client * @param string $rerr Error message from client + * @param array $rhdrs Response headers + * @param string $rbody Error body from client (if any) * @return false */ - protected function handleError( $msg, $rcode, $rerr ) { - $this->logger->error( "$msg : ({code}) {error}", [ + protected function handleError( $msg, $rcode, $rerr, $rhdrs, $rbody ) { + $message = "$msg : ({code}) {error}"; + $context = [ 'code' => $rcode, 'error' => $rerr - ] ); + ]; + + if ( $this->extendedErrorBodyFields !== [] ) { + $body = $this->decodeBody( $rbody ); + if ( $body ) { + $extraFields = ''; + foreach ( $this->extendedErrorBodyFields as $field ) { + if ( isset( $body[$field] ) ) { + $extraFields .= " : ({$field}) {$body[$field]}"; + } + } + if ( $extraFields !== '' ) { + $message .= " {extra_fields}"; + $context['extra_fields'] = $extraFields; + } + } + } + + $this->logger->error( $message, $context ); $this->setLastError( $rcode === 0 ? self::ERR_UNREACHABLE : self::ERR_UNEXPECTED ); return false; } diff --git a/includes/libs/objectcache/RedisBagOStuff.php b/includes/libs/objectcache/RedisBagOStuff.php index 2c74d45916..743b9eba2c 100644 --- a/includes/libs/objectcache/RedisBagOStuff.php +++ b/includes/libs/objectcache/RedisBagOStuff.php @@ -106,7 +106,7 @@ class RedisBagOStuff extends BagOStuff { return $result; } - public function set( $key, $value, $expiry = 0, $flags = 0 ) { + protected function doSet( $key, $value, $expiry = 0, $flags = 0 ) { list( $server, $conn ) = $this->getConnection( $key ); if ( !$conn ) { return false; @@ -128,7 +128,7 @@ class RedisBagOStuff extends BagOStuff { return $result; } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { list( $server, $conn ) = $this->getConnection( $key ); if ( !$conn ) { return false; @@ -146,7 +146,7 @@ class RedisBagOStuff extends BagOStuff { return $result; } - public function getMulti( array $keys, $flags = 0 ) { + public function doGetMulti( array $keys, $flags = 0 ) { $batches = []; $conns = []; foreach ( $keys as $key ) { @@ -351,25 +351,6 @@ class RedisBagOStuff extends BagOStuff { return $result; } - /** - * @param mixed $data - * @return string - */ - protected function serialize( $data ) { - // Serialize anything but integers so INCR/DECR work - // Do not store integer-like strings as integers to avoid type confusion (T62563) - return is_int( $data ) ? $data : serialize( $data ); - } - - /** - * @param string $data - * @return mixed - */ - protected function unserialize( $data ) { - $int = intval( $data ); - return $data === (string)$int ? $int : unserialize( $data ); - } - /** * Get a Redis object with a connection suitable for fetching the specified key * @param string $key @@ -446,7 +427,7 @@ class RedisBagOStuff extends BagOStuff { * not. The safest response for us is to explicitly destroy the connection * object and let it be reopened during the next request. * @param RedisConnRef $conn - * @param Exception $e + * @param RedisException $e */ protected function handleException( RedisConnRef $conn, $e ) { $this->setLastError( BagOStuff::ERR_UNEXPECTED ); diff --git a/includes/libs/objectcache/ReplicatedBagOStuff.php b/includes/libs/objectcache/ReplicatedBagOStuff.php index 70f9096001..f79c1ff65f 100644 --- a/includes/libs/objectcache/ReplicatedBagOStuff.php +++ b/includes/libs/objectcache/ReplicatedBagOStuff.php @@ -75,7 +75,7 @@ class ReplicatedBagOStuff extends BagOStuff { } public function get( $key, $flags = 0 ) { - return ( $flags & self::READ_LATEST ) + return ( ( $flags & self::READ_LATEST ) == self::READ_LATEST ) ? $this->writeStore->get( $key, $flags ) : $this->readStore->get( $key, $flags ); } @@ -164,4 +164,24 @@ class ReplicatedBagOStuff extends BagOStuff { protected function doGet( $key, $flags = 0, &$casToken = null ) { throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); } + + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function doDelete( $key, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function doGetMulti( array $keys, $flags = 0 ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function serialize( $value ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } + + protected function unserialize( $blob ) { + throw new LogicException( __METHOD__ . ': proxy class does not need this method.' ); + } } diff --git a/includes/libs/objectcache/WANObjectCache.php b/includes/libs/objectcache/WANObjectCache.php index dac3421786..1d8662a3c8 100644 --- a/includes/libs/objectcache/WANObjectCache.php +++ b/includes/libs/objectcache/WANObjectCache.php @@ -1464,7 +1464,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface { * @param string $kClass * @param float $elapsed Seconds spent regenerating the value * @param float $lockTSE - * @param $hasLock bool + * @param bool $hasLock * @return bool Whether it is OK to proceed with a key set operation */ private function checkAndSetCooloff( $key, $kClass, $elapsed, $lockTSE, $hasLock ) { diff --git a/includes/libs/objectcache/WinCacheBagOStuff.php b/includes/libs/objectcache/WinCacheBagOStuff.php index 8c419b22cd..9d7e143682 100644 --- a/includes/libs/objectcache/WinCacheBagOStuff.php +++ b/includes/libs/objectcache/WinCacheBagOStuff.php @@ -36,7 +36,7 @@ class WinCacheBagOStuff extends BagOStuff { return false; } - $value = unserialize( $blob ); + $value = $this->unserialize( $blob ); if ( $value !== false ) { $casToken = (string)$blob; // don't bother hashing this } @@ -67,8 +67,8 @@ class WinCacheBagOStuff extends BagOStuff { return $success; } - public function set( $key, $value, $expire = 0, $flags = 0 ) { - $result = wincache_ucache_set( $key, serialize( $value ), $expire ); + protected function doSet( $key, $value, $expire = 0, $flags = 0 ) { + $result = wincache_ucache_set( $key, $this->serialize( $value ), $expire ); // false positive, wincache_ucache_set returns an empty array // in some circumstances. @@ -77,7 +77,11 @@ class WinCacheBagOStuff extends BagOStuff { } public function add( $key, $value, $exptime = 0, $flags = 0 ) { - $result = wincache_ucache_add( $key, serialize( $value ), $exptime ); + if ( wincache_ucache_exists( $key ) ) { + return false; // avoid warnings + } + + $result = wincache_ucache_add( $key, $this->serialize( $value ), $exptime ); // false positive, wincache_ucache_add returns an empty array // in some circumstances. @@ -85,7 +89,7 @@ class WinCacheBagOStuff extends BagOStuff { return ( $result === [] || $result === true ); } - public function delete( $key, $flags = 0 ) { + protected function doDelete( $key, $flags = 0 ) { wincache_ucache_delete( $key ); return true; diff --git a/includes/libs/objectcache/serialized/SerializedValueContainer.php b/includes/libs/objectcache/serialized/SerializedValueContainer.php new file mode 100644 index 0000000000..7c7d8aa153 --- /dev/null +++ b/includes/libs/objectcache/serialized/SerializedValueContainer.php @@ -0,0 +1,66 @@ + self::SCHEMA_UNIFIED, + self::UNIFIED_DATA => $serialized + ]; + } + + /** + * @param string[] $segmentHashList Ordered list of hashes for each segment + * @return stdClass + */ + public static function newSegmented( array $segmentHashList ) { + return (object)[ + self::SCHEMA => self::SCHEMA_SEGMENTED, + self::SEGMENTED_HASHES => $segmentHashList + ]; + } + + /** + * @param mixed $value + * @return bool + */ + public static function isUnified( $value ) { + return self::instanceOf( $value, self::SCHEMA_UNIFIED ); + } + + /** + * @param mixed $value + * @return bool + */ + public static function isSegmented( $value ) { + return self::instanceOf( $value, self::SCHEMA_SEGMENTED ); + } + + /** + * @param mixed $value + * @param string $schema SCHEMA_* class constant + * @return bool + */ + private static function instanceOf( $value, $schema ) { + return ( + $value instanceof stdClass && + property_exists( $value, self::SCHEMA ) && + $value->{self::SCHEMA} === $schema + ); + } +} diff --git a/includes/libs/rdbms/ChronologyProtector.php b/includes/libs/rdbms/ChronologyProtector.php index fa454c88d1..24b54026cd 100644 --- a/includes/libs/rdbms/ChronologyProtector.php +++ b/includes/libs/rdbms/ChronologyProtector.php @@ -30,7 +30,10 @@ use Wikimedia\WaitConditionLoop; use BagOStuff; /** - * Class for ensuring a consistent ordering of events as seen by the user, despite replication. + * Helper class for mitigating DB replication lag in order to provide "session consistency" + * + * This helps to ensure a consistent ordering of events as seen by an client + * * Kind of like Hawking's [[Chronology Protection Agency]]. */ class ChronologyProtector implements LoggerAwareInterface { @@ -82,7 +85,7 @@ class ChronologyProtector implements LoggerAwareInterface { if ( isset( $client['clientId'] ) ) { $this->clientId = $client['clientId']; } else { - $this->clientId = strlen( $secret ) + $this->clientId = ( $secret != '' ) ? hash_hmac( 'md5', $client['ip'] . "\n" . $client['agent'], $secret ) : md5( $client['ip'] . "\n" . $client['agent'] ); } @@ -127,51 +130,53 @@ class ChronologyProtector implements LoggerAwareInterface { } /** - * Initialise a ILoadBalancer to give it appropriate chronology protection. + * Apply the "session consistency" DB replication position to a new ILoadBalancer * - * If the stash has a previous master position recorded, this will try to - * make sure that the next query to a replica DB of that master will see changes up + * If the stash has a previous master position recorded, this will try to make + * sure that the next query to a replica DB of that master will see changes up * to that position by delaying execution. The delay may timeout and allow stale * data if no non-lagged replica DBs are available. * + * This method should only be called from LBFactory. + * * @param ILoadBalancer $lb * @return void */ - public function initLB( ILoadBalancer $lb ) { - if ( !$this->enabled || $lb->getServerCount() <= 1 ) { - return; // non-replicated setup or disabled + public function applySessionReplicationPosition( ILoadBalancer $lb ) { + if ( !$this->enabled ) { + return; // disabled } - $this->initPositions(); - $masterName = $lb->getServerName( $lb->getWriterIndex() ); - if ( - isset( $this->startupPositions[$masterName] ) && - $this->startupPositions[$masterName] instanceof DBMasterPos - ) { - $pos = $this->startupPositions[$masterName]; - $this->logger->debug( __METHOD__ . ": LB for '$masterName' set to pos $pos\n" ); + $startupPositions = $this->getStartupMasterPositions(); + + $pos = $startupPositions[$masterName] ?? null; + if ( $pos instanceof DBMasterPos ) { + $this->logger->debug( __METHOD__ . ": pos for DB '$masterName' set to '$pos'\n" ); $lb->waitFor( $pos ); } } /** - * Notify the ChronologyProtector that the ILoadBalancer is about to shut - * down. Saves replication positions. + * Save the "session consistency" DB replication position for an end-of-life ILoadBalancer + * + * This saves the replication position of the master DB if this request made writes to it. + * + * This method should only be called from LBFactory. * * @param ILoadBalancer $lb * @return void */ - public function shutdownLB( ILoadBalancer $lb ) { + public function storeSessionReplicationPosition( ILoadBalancer $lb ) { if ( !$this->enabled ) { - return; // not enabled + return; // disabled } elseif ( !$lb->hasOrMadeRecentMasterChanges( INF ) ) { // Only save the position if writes have been done on the connection return; } $masterName = $lb->getServerName( $lb->getWriterIndex() ); - if ( $lb->getServerCount() > 1 ) { + if ( $lb->hasStreamingReplicaServers() ) { $pos = $lb->getMasterPos(); if ( $pos ) { $this->logger->debug( __METHOD__ . ": LB for '$masterName' has pos $pos\n" ); @@ -209,10 +214,13 @@ class ChronologyProtector implements LoggerAwareInterface { } if ( $this->shutdownPositions === [] ) { + $this->logger->debug( __METHOD__ . ": no master positions to save\n" ); + return []; // nothing to save } - $this->logger->debug( __METHOD__ . ": saving master pos for " . + $this->logger->debug( + __METHOD__ . ": saving master pos for " . implode( ', ', array_keys( $this->shutdownPositions ) ) . "\n" ); @@ -282,12 +290,14 @@ class ChronologyProtector implements LoggerAwareInterface { /** * Load in previous master positions for the client */ - protected function initPositions() { + protected function getStartupMasterPositions() { if ( $this->initialized ) { - return; + return $this->startupPositions; } $this->initialized = true; + $this->logger->debug( __METHOD__ . ": client ID is {$this->clientId} (read)\n" ); + if ( $this->wait ) { // If there is an expectation to see master positions from a certain write // index or higher, then block until it appears, or until a timeout is reached. @@ -344,6 +354,8 @@ class ChronologyProtector implements LoggerAwareInterface { $this->startupPositions = []; $this->logger->debug( __METHOD__ . ": key is {$this->key} (unread)\n" ); } + + return $this->startupPositions; } /** diff --git a/includes/libs/rdbms/TransactionProfiler.php b/includes/libs/rdbms/TransactionProfiler.php index e4dad01dda..c89820d3ab 100644 --- a/includes/libs/rdbms/TransactionProfiler.php +++ b/includes/libs/rdbms/TransactionProfiler.php @@ -218,7 +218,7 @@ class TransactionProfiler implements LoggerAwareInterface { * * This assumes that all queries are synchronous (non-overlapping) * - * @param string $query Function name or generalized SQL + * @param string|GeneralizedSql $query Function name or generalized SQL * @param float $sTime Starting UNIX wall time * @param bool $isWrite Whether this is a write query * @param int $n Number of affected/read rows @@ -229,11 +229,11 @@ class TransactionProfiler implements LoggerAwareInterface { if ( $isWrite && $n > $this->expect['maxAffected'] ) { $this->logger->warning( - "Query affected $n row(s):\n" . $query . "\n" . + "Query affected $n row(s):\n" . self::queryString( $query ) . "\n" . ( new RuntimeException() )->getTraceAsString() ); } elseif ( !$isWrite && $n > $this->expect['readQueryRows'] ) { $this->logger->warning( - "Query returned $n row(s):\n" . $query . "\n" . + "Query returned $n row(s):\n" . self::queryString( $query ) . "\n" . ( new RuntimeException() )->getTraceAsString() ); } @@ -341,7 +341,8 @@ class TransactionProfiler implements LoggerAwareInterface { $trace = ''; foreach ( $this->dbTrxMethodTimes[$name] as $i => $info ) { list( $query, $sTime, $end ) = $info; - $trace .= sprintf( "%d\t%.6f\t%s\n", $i, ( $end - $sTime ), $query ); + $trace .= sprintf( + "%d\t%.6f\t%s\n", $i, ( $end - $sTime ), self::queryString( $query ) ); } $this->logger->warning( "Sub-optimal transaction on DB(s) [{dbs}]: \n{trace}", [ 'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ), @@ -354,7 +355,7 @@ class TransactionProfiler implements LoggerAwareInterface { /** * @param string $expect - * @param string $query + * @param string|GeneralizedSql $query * @param string|float|int $actual */ protected function reportExpectationViolated( $expect, $query, $actual ) { @@ -370,8 +371,16 @@ class TransactionProfiler implements LoggerAwareInterface { 'max' => $this->expect[$expect], 'by' => $this->expectBy[$expect], 'actual' => $actual, - 'query' => $query + 'query' => self::queryString( $query ) ] ); } + + /** + * @param GeneralizedSql|string $query + * @return string + */ + private static function queryString( $query ) { + return $query instanceof GeneralizedSql ? $query->stringify() : $query; + } } diff --git a/includes/libs/rdbms/connectionmanager/ConnectionManager.php b/includes/libs/rdbms/connectionmanager/ConnectionManager.php index 27e6138102..50a0b0e1f8 100644 --- a/includes/libs/rdbms/connectionmanager/ConnectionManager.php +++ b/includes/libs/rdbms/connectionmanager/ConnectionManager.php @@ -73,7 +73,7 @@ class ConnectionManager { * @param int $i * @param string[]|null $groups * - * @return Database + * @return IDatabase */ private function getConnection( $i, array $groups = null ) { $groups = $groups === null ? $this->groups : $groups; @@ -97,7 +97,7 @@ class ConnectionManager { * * @since 1.29 * - * @return Database + * @return IDatabase */ public function getWriteConnection() { return $this->getConnection( DB_MASTER ); @@ -111,7 +111,7 @@ class ConnectionManager { * * @param string[]|null $groups * - * @return Database + * @return IDatabase */ public function getReadConnection( array $groups = null ) { $groups = $groups === null ? $this->groups : $groups; diff --git a/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php b/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php index aa3bea8fdc..ccb73d7256 100644 --- a/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php +++ b/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php @@ -64,7 +64,7 @@ class SessionConsistentConnectionManager extends ConnectionManager { * * @param string[]|null $groups * - * @return Database + * @return IDatabase */ public function getReadConnection( array $groups = null ) { if ( $this->forceWriteConnection ) { @@ -77,7 +77,7 @@ class SessionConsistentConnectionManager extends ConnectionManager { /** * @since 1.29 * - * @return Database + * @return IDatabase */ public function getWriteConnection() { $this->prepareForUpdates(); diff --git a/includes/libs/rdbms/database/DBConnRef.php b/includes/libs/rdbms/database/DBConnRef.php index b216892486..76701f853a 100644 --- a/includes/libs/rdbms/database/DBConnRef.php +++ b/includes/libs/rdbms/database/DBConnRef.php @@ -5,8 +5,23 @@ namespace Wikimedia\Rdbms; use InvalidArgumentException; /** - * Helper class to handle automatically marking connections as reusable (via RAII pattern) - * as well handling deferring the actual network connection until the handle is used + * Helper class used for automatically marking an IDatabase connection as reusable (once it no + * longer matters which DB domain is selected) and for deferring the actual network connection + * + * This uses an RAII-style pattern where calling code is expected to keep the returned reference + * handle as a function variable that falls out of scope when no longer needed. This avoids the + * need for matching reuseConnection() calls for every "return" statement as well as the tedious + * use of try/finally. + * + * @par Example: + * @code + * function getRowData() { + * $conn = $this->lb->getConnectedRef( DB_REPLICA ); + * $row = $conn->select( ... ); + * return $row ? (array)$row : false; + * // $conn falls out of scope and $this->lb->reuseConnection() gets called + * } + * @endcode * * @ingroup Database * @since 1.22 @@ -28,14 +43,14 @@ class DBConnRef implements IDatabase { /** * @param ILoadBalancer $lb Connection manager for $conn - * @param Database|array $conn Database or (server index, query groups, domain, flags) + * @param IDatabase|array $conn Database or (server index, query groups, domain, flags) * @param int $role The type of connection asked for; one of DB_MASTER/DB_REPLICA * @internal This method should not be called outside of LoadBalancer */ public function __construct( ILoadBalancer $lb, $conn, $role ) { $this->lb = $lb; $this->role = $role; - if ( $conn instanceof Database ) { + if ( $conn instanceof IDatabase && !( $conn instanceof DBConnRef ) ) { $this->conn = $conn; // live handle } elseif ( is_array( $conn ) && count( $conn ) >= 4 && $conn[self::FLD_DOMAIN] !== false ) { $this->params = $conn; @@ -461,7 +476,7 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } - public function buildLike() { + public function buildLike( $param ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -598,6 +613,10 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) { + return $this->__call( __FUNCTION__, func_get_args() ); + } + public function setTransactionListener( $name, callable $callback = null ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -740,6 +759,19 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function __toString() { + if ( $this->conn === null ) { + // spl_object_id is PHP >= 7.2 + $id = function_exists( 'spl_object_id' ) + ? spl_object_id( $this ) + : spl_object_hash( $this ); + + return $this->getType() . ' object #' . $id; + } + + return $this->__call( __FUNCTION__, func_get_args() ); + } + /** * Error out if the role is not DB_MASTER * diff --git a/includes/libs/rdbms/database/Database.php b/includes/libs/rdbms/database/Database.php index 6e30d3fca4..92b94716d8 100644 --- a/includes/libs/rdbms/database/Database.php +++ b/includes/libs/rdbms/database/Database.php @@ -38,6 +38,7 @@ use InvalidArgumentException; use UnexpectedValueException; use Exception; use RuntimeException; +use Throwable; /** * Relational database abstraction object @@ -46,37 +47,6 @@ use RuntimeException; * @since 1.28 */ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAwareInterface { - /** Number of times to re-try an operation in case of deadlock */ - const DEADLOCK_TRIES = 4; - /** Minimum time to wait before retry, in microseconds */ - const DEADLOCK_DELAY_MIN = 500000; - /** Maximum time to wait before retry */ - const DEADLOCK_DELAY_MAX = 1500000; - - /** How long before it is worth doing a dummy query to test the connection */ - const PING_TTL = 1.0; - const PING_QUERY = 'SELECT 1 AS ping'; - - const TINY_WRITE_SEC = 0.010; - const SLOW_WRITE_SEC = 0.500; - const SMALL_WRITE_ROWS = 100; - - /** @var string Lock granularity is on the level of the entire database */ - const ATTR_DB_LEVEL_LOCKING = 'db-level-locking'; - /** @var string The SCHEMA keyword refers to a grouping of tables in a database */ - const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas'; - - /** @var int New Database instance will not be connected yet when returned */ - const NEW_UNCONNECTED = 0; - /** @var int New Database instance will already be connected when returned */ - const NEW_CONNECTED = 1; - - /** @var string SQL query */ - protected $lastQuery = ''; - /** @var float|bool UNIX timestamp of last write query */ - protected $lastWriteTime = false; - /** @var string|bool */ - protected $phpError = false; /** @var string Server that this instance is currently connected to */ protected $server; /** @var string User that this instance is currently connected under the name of */ @@ -91,8 +61,23 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected $cliMode; /** @var string Agent name for query profiling */ protected $agent; + /** @var int Bitfield of class DBO_* constants */ + protected $flags; + /** @var array LoadBalancer tracking information */ + protected $lbInfo = []; + /** @var array|bool Variables use for schema element placeholders */ + protected $schemaVars = false; /** @var array Parameters used by initConnection() to establish a connection */ protected $connectionParams = []; + /** @var array SQL variables values to use for all new connections */ + protected $connectionVariables = []; + /** @var string Current SQL query delimiter */ + protected $delimiter = ';'; + /** @var string|bool|null Stashed value of html_errors INI setting */ + protected $htmlErrors; + /** @var int Row batch size to use for emulated INSERT SELECT queries */ + protected $nonNativeInsertSelectBatchSize = 10000; + /** @var BagOStuff APC cache */ protected $srvCache; /** @var LoggerInterface */ @@ -103,177 +88,98 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected $errorLogger; /** @var callable Deprecation logging callback */ protected $deprecationLogger; - + /** @var callable|null */ + protected $profiler; + /** @var TransactionProfiler */ + protected $trxProfiler; + /** @var DatabaseDomain */ + protected $currentDomain; /** @var object|resource|null Database connection */ - protected $conn = null; - /** @var bool */ - protected $opened = false; + protected $conn; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxIdleCallbacks = []; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxPreCommitCallbacks = []; - /** @var array[] List of (callable, method name, atomic section id) */ - protected $trxEndCallbacks = []; - /** @var callable[] Map of (name => callable) */ - protected $trxRecurringCallbacks = []; - /** @var bool Whether to suppress triggering of transaction end callbacks */ - protected $trxEndCallbacksSuppressed = false; + /** @var IDatabase|null Lazy handle to the master DB this server replicates from */ + private $lazyMasterHandle; - /** @var int */ - protected $flags; - /** @var array */ - protected $lbInfo = []; - /** @var array|bool */ - protected $schemaVars = false; - /** @var array */ - protected $sessionVars = []; - /** @var array|null */ - protected $preparedArgs; - /** @var string|bool|null Stashed value of html_errors INI setting */ - protected $htmlErrors; - /** @var string */ - protected $delimiter = ';'; - /** @var DatabaseDomain */ - protected $currentDomain; - /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */ - protected $affectedRowCount; + /** @var array Map of (name => 1) for locks obtained via lock() */ + protected $sessionNamedLocks = []; + /** @var array Map of (table name => 1) for TEMPORARY tables */ + protected $sessionTempTables = []; - /** - * @var int Transaction status - */ + /** @var string ID of the active transaction or the empty string otherwise */ + protected $trxShortId = ''; + /** @var int Transaction status */ protected $trxStatus = self::STATUS_TRX_NONE; - /** - * @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR - */ + /** @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR */ protected $trxStatusCause; - /** - * @var array|null If wasKnownStatementRollbackError() prevented trxStatus from being set, - * the relevant details are stored here. - */ - protected $trxStatusIgnoredCause; - /** - * Either 1 if a transaction is active or 0 otherwise. - * The other Trx fields may not be meaningfull if this is 0. - * - * @var int - */ - protected $trxLevel = 0; - /** - * Either a short hexidecimal string if a transaction is active or "" - * - * @var string - * @see Database::trxLevel - */ - protected $trxShortId = ''; - /** - * The UNIX time that the transaction started. Callers can assume that if - * snapshot isolation is used, then the data is *at least* up to date to that - * point (possibly more up-to-date since the first SELECT defines the snapshot). - * - * @var float|null - * @see Database::trxLevel - */ + /** @var array|null Error details of the last statement-only rollback */ + private $trxStatusIgnoredCause; + /** @var float|null UNIX timestamp at the time of BEGIN for the last transaction */ private $trxTimestamp = null; - /** @var float Lag estimate at the time of BEGIN */ + /** @var float Replication lag estimate at the time of BEGIN for the last transaction */ private $trxReplicaLag = null; - /** - * Remembers the function name given for starting the most recent transaction via begin(). - * Used to provide additional context for error reporting. - * - * @var string - * @see Database::trxLevel - */ + /** @var string Name of the function that start the last transaction */ private $trxFname = null; - /** - * Record if possible write queries were done in the last transaction started - * - * @var bool - * @see Database::trxLevel - */ + /** @var bool Whether possible write queries were done in the last transaction started */ private $trxDoneWrites = false; - /** - * Record if the current transaction was started implicitly due to DBO_TRX being set. - * - * @var bool - * @see Database::trxLevel - */ + /** @var bool Whether the current transaction was started implicitly due to DBO_TRX */ private $trxAutomatic = false; - /** - * Counter for atomic savepoint identifiers. Reset when a new transaction begins. - * - * @var int - */ + /** @var int Counter for atomic savepoint identifiers (reset with each transaction) */ private $trxAtomicCounter = 0; - /** - * Array of levels of atomicity within transactions - * - * @var array List of (name, unique ID, savepoint ID) - */ + /** @var array List of (name, unique ID, savepoint ID) for each active atomic section level */ private $trxAtomicLevels = []; - /** - * Record if the current transaction was started implicitly by Database::startAtomic - * - * @var bool - */ + /** @var bool Whether the current transaction was started implicitly by startAtomic() */ private $trxAutomaticAtomic = false; - /** - * Track the write query callers of the current transaction - * - * @var string[] - */ + /** @var string[] Write query callers of the current transaction */ private $trxWriteCallers = []; - /** - * @var float Seconds spent in write queries for the current transaction - */ + /** @var float Seconds spent in write queries for the current transaction */ private $trxWriteDuration = 0.0; - /** - * @var int Number of write queries for the current transaction - */ + /** @var int Number of write queries for the current transaction */ private $trxWriteQueryCount = 0; - /** - * @var int Number of rows affected by write queries for the current transaction - */ + /** @var int Number of rows affected by write queries for the current transaction */ private $trxWriteAffectedRows = 0; - /** - * @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries - */ + /** @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries */ private $trxWriteAdjDuration = 0.0; - /** - * @var int Number of write queries counted in trxWriteAdjDuration - */ + /** @var int Number of write queries counted in trxWriteAdjDuration */ private $trxWriteAdjQueryCount = 0; - /** - * @var float RTT time estimate - */ - private $rttEstimate = 0.0; - - /** @var array Map of (name => 1) for locks obtained via lock() */ - private $namedLocksHeld = []; - /** @var array Map of (table name => 1) for TEMPORARY tables */ - protected $sessionTempTables = []; - - /** @var IDatabase|null Lazy handle to the master DB this server replicates from */ - private $lazyMasterHandle; - - /** @var float UNIX timestamp */ - protected $lastPing = 0.0; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxIdleCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxPreCommitCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxEndCallbacks = []; + /** @var array[] List of (callable, method name, atomic section id) */ + private $trxSectionCancelCallbacks = []; + /** @var callable[] Map of (name => callable) */ + private $trxRecurringCallbacks = []; + /** @var bool Whether to suppress triggering of transaction end callbacks */ + private $trxEndCallbacksSuppressed = false; /** @var int[] Prior flags member variable values */ private $priorFlags = []; - /** @var callable|null */ - protected $profiler; - /** @var TransactionProfiler */ - protected $trxProfiler; + /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */ + protected $affectedRowCount; - /** @var int */ - protected $nonNativeInsertSelectBatchSize = 10000; + /** @var float UNIX timestamp */ + private $lastPing = 0.0; + /** @var string The last SQL query attempted */ + private $lastQuery = ''; + /** @var float|bool UNIX timestamp of last write query */ + private $lastWriteTime = false; + /** @var string|bool */ + private $lastPhpError = false; + /** @var float Query rount trip time estimate */ + private $lastRoundTripEstimate = 0.0; - /** @var string Idiom used when a cancelable atomic section started the transaction */ - private static $NOT_APPLICABLE = 'n/a'; - /** @var string Prefix to the atomic section counter used to make savepoint IDs */ - private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic'; + /** @var string Lock granularity is on the level of the entire database */ + const ATTR_DB_LEVEL_LOCKING = 'db-level-locking'; + /** @var string The SCHEMA keyword refers to a grouping of tables in a database */ + const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas'; + + /** @var int New Database instance will not be connected yet when returned */ + const NEW_UNCONNECTED = 0; + /** @var int New Database instance will already be connected when returned */ + const NEW_CONNECTED = 1; /** @var int Transaction is in a error state requiring a full or savepoint rollback */ const STATUS_TRX_ERROR = 1; @@ -282,10 +188,30 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** @var int No transaction is active */ const STATUS_TRX_NONE = 3; + /** @var string Idiom used when a cancelable atomic section started the transaction */ + private static $NOT_APPLICABLE = 'n/a'; + /** @var string Prefix to the atomic section counter used to make savepoint IDs */ + private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic'; + /** @var int Writes to this temporary table do not affect lastDoneWrites() */ - const TEMP_NORMAL = 1; + private static $TEMP_NORMAL = 1; /** @var int Writes to this temporary table effect lastDoneWrites() */ - const TEMP_PSEUDO_PERMANENT = 2; + private static $TEMP_PSEUDO_PERMANENT = 2; + + /** Number of times to re-try an operation in case of deadlock */ + private static $DEADLOCK_TRIES = 4; + /** Minimum time to wait before retry, in microseconds */ + private static $DEADLOCK_DELAY_MIN = 500000; + /** Maximum time to wait before retry */ + private static $DEADLOCK_DELAY_MAX = 1500000; + + /** How long before it is worth doing a dummy query to test the connection */ + private static $PING_TTL = 1.0; + private static $PING_QUERY = 'SELECT 1 AS ping'; + + private static $TINY_WRITE_SEC = 0.010; + private static $SLOW_WRITE_SEC = 0.500; + private static $SMALL_WRITE_ROWS = 100; /** * @note exceptions for missing libraries/drivers should be thrown in initConnection() @@ -311,7 +237,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // Disregard deprecated DBO_IGNORE flag (T189999) $this->flags &= ~self::DBO_IGNORE; - $this->sessionVars = $params['variables']; + $this->connectionVariables = $params['variables']; $this->srvCache = $params['srvCache'] ?? new HashBagOStuff(); @@ -381,7 +307,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @param string $dbName Database name * @param string|null $schema Database schema name * @param string $tablePrefix Table prefix - * @return bool * @throws DBConnectionError */ abstract protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ); @@ -585,12 +510,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $res; } - public function trxLevel() { - return $this->trxLevel; + final public function trxLevel() { + return ( $this->trxShortId != '' ) ? 1 : 0; } public function trxTimestamp() { - return $this->trxLevel ? $this->trxTimestamp : null; + return $this->trxLevel() ? $this->trxTimestamp : null; } /** @@ -693,20 +618,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function writesPending() { - return $this->trxLevel && $this->trxDoneWrites; + return $this->trxLevel() && $this->trxDoneWrites; } public function writesOrCallbacksPending() { - return $this->trxLevel && ( + return $this->trxLevel() && ( $this->trxDoneWrites || $this->trxIdleCallbacks || $this->trxPreCommitCallbacks || - $this->trxEndCallbacks + $this->trxEndCallbacks || + $this->trxSectionCancelCallbacks ); } public function preCommitCallbacksPending() { - return $this->trxLevel && $this->trxPreCommitCallbacks; + return $this->trxLevel() && $this->trxPreCommitCallbacks; } /** @@ -724,7 +650,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) { - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { return false; } elseif ( !$this->trxDoneWrites ) { return 0.0; @@ -748,13 +674,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $applyTime = max( $this->trxWriteAdjDuration - $rttAdjTotal, 0 ); // For omitted queries, make them count as something at least $omitted = $this->trxWriteQueryCount - $this->trxWriteAdjQueryCount; - $applyTime += self::TINY_WRITE_SEC * $omitted; + $applyTime += self::$TINY_WRITE_SEC * $omitted; return $applyTime; } public function pendingWriteCallers() { - return $this->trxLevel ? $this->trxWriteCallers : []; + return $this->trxLevel() ? $this->trxWriteCallers : []; } public function pendingWriteRowsAffected() { @@ -774,7 +700,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware foreach ( [ $this->trxIdleCallbacks, $this->trxPreCommitCallbacks, - $this->trxEndCallbacks + $this->trxEndCallbacks, + $this->trxSectionCancelCallbacks ] as $callbacks ) { foreach ( $callbacks as $callback ) { $fnames[] = $callback[1]; @@ -794,7 +721,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function isOpen() { - return $this->opened; + return (bool)$this->conn; } public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) { @@ -874,7 +801,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * Set a custom error handler for logging errors during database connection */ protected function installErrorHandler() { - $this->phpError = false; + $this->lastPhpError = false; $this->htmlErrors = ini_set( 'html_errors', '0' ); set_error_handler( [ $this, 'connectionErrorLogger' ] ); } @@ -897,8 +824,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @return string|bool Last PHP error for this DB (typically connection errors) */ protected function getLastPHPError() { - if ( $this->phpError ) { - $error = preg_replace( '!\[\]!', '', $this->phpError ); + if ( $this->lastPhpError ) { + $error = preg_replace( '!\[\]!', '', $this->lastPhpError ); $error = preg_replace( '!^.*?:\s?(.*)$!', '$1', $error ); return $error; @@ -915,7 +842,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @param string $errstr */ public function connectionErrorLogger( $errno, $errstr ) { - $this->phpError = $errstr; + $this->lastPhpError = $errstr; } /** @@ -938,11 +865,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function close() { $exception = null; // error to throw after disconnecting - $wasOpen = $this->opened; + $wasOpen = (bool)$this->conn; // This should mostly do nothing if the connection is already closed if ( $this->conn ) { // Roll back any dangling transaction first - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { if ( $this->trxAtomicLevels ) { // Cannot let incomplete atomic sections be committed $levels = $this->flatAtomicSectionList(); @@ -987,7 +914,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } $this->conn = false; - $this->opened = false; // Throw any unexpected errors after having disconnected if ( $exception instanceof Exception ) { @@ -1019,7 +945,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @throws DBUnexpectedError */ - protected function assertHasConnectionHandle() { + final protected function assertHasConnectionHandle() { if ( !$this->isOpen() ) { throw new DBUnexpectedError( $this, "DB connection was already closed." ); } @@ -1028,7 +954,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** * Make sure that this server is not marked as a replica nor read-only as a sanity check * - * @throws DBUnexpectedError + * @throws DBReadOnlyRoleError + * @throws DBReadOnlyError */ protected function assertIsWritableMaster() { if ( $this->getLBInfo( 'replica' ) === true ) { @@ -1050,24 +977,25 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ abstract protected function closeConnection(); - /** - * @deprecated since 1.32 - * @param string $error Fallback message, if none is given by DB - * @throws DBConnectionError - */ - public function reportConnectionError( $error = 'Unknown error' ) { - call_user_func( $this->deprecationLogger, 'Use of ' . __METHOD__ . ' is deprecated.' ); - throw new DBConnectionError( $this, $this->lastError() ?: $error ); - } - /** * Run a query and return a DBMS-dependent wrapper or boolean * + * This is meant to handle the basic command of actually sending a query to the + * server via the driver. No implicit transaction, reconnection, nor retry logic + * should happen here. The higher level query() method is designed to handle those + * sorts of concerns. This method should not trigger such higher level methods. + * + * The lastError() and lastErrno() methods should meaningfully reflect what error, + * if any, occured during the last call to this method. Methods like executeQuery(), + * query(), select(), insert(), update(), delete(), and upsert() implement their calls + * to doQuery() such that an immediately subsequent call to lastError()/lastErrno() + * meaningfully reflects any error that occured during that public query method call. + * * For SELECT queries, this returns either: * - a) A driver-specific value/resource, only on success. This can be iterated * over by calling fetchObject()/fetchRow() until there are no more rows. - * Alternatively, the result can be passed to resultObject() to obtain a - * ResultWrapper instance which can then be iterated over via "foreach". + * Alternatively, the result can be passed to resultObject() to obtain an + * IResultWrapper instance which can then be iterated over via "foreach". * - b) False, on any query failure * * For non-SELECT queries, this returns either: @@ -1107,11 +1035,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // for all queries within a request. Use cases: // - Treating these as writes would trigger ChronologyProtector (see method doc). // - We use this method to reject writes to replicas, but we need to allow - // use of transactions on replicas for read snapshots. This fine given + // use of transactions on replicas for read snapshots. This is fine given // that transactions by themselves don't make changes, only actual writes // within the transaction matter, which we still detect. return !preg_match( - '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|\(SELECT)\b/i', + '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|USE|\(SELECT)\b/i', $sql ); } @@ -1140,7 +1068,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected function isTransactableQuery( $sql ) { return !in_array( $this->getQueryVerb( $sql ), - [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER' ], + [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER', 'USE', 'SHOW' ], true ); } @@ -1148,9 +1076,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** * @param string $sql A SQL query * @param bool $pseudoPermanent Treat any table from CREATE TEMPORARY as pseudo-permanent - * @return int|null A self::TEMP_* constant for temp table operations or null otherwise + * @return array A n-tuple of: + * - int|null: A self::TEMP_* constant for temp table operations or null otherwise + * - string|null: The name of the new temporary table $sql creates, or null + * - string|null: The name of the temporary table that $sql drops, or null */ - protected function registerTempTableWrite( $sql, $pseudoPermanent ) { + protected function getTempWrites( $sql, $pseudoPermanent ) { static $qt = '[`"\']?(\w+)[`"\']?'; // quoted table if ( preg_match( @@ -1158,197 +1089,255 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $sql, $matches ) ) { - $type = $pseudoPermanent ? self::TEMP_PSEUDO_PERMANENT : self::TEMP_NORMAL; - $this->sessionTempTables[$matches[1]] = $type; + $type = $pseudoPermanent ? self::$TEMP_PSEUDO_PERMANENT : self::$TEMP_NORMAL; - return $type; + return [ $type, $matches[1], null ]; } elseif ( preg_match( '/^DROP\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i', $sql, $matches ) ) { - $type = $this->sessionTempTables[$matches[1]] ?? null; - unset( $this->sessionTempTables[$matches[1]] ); - - return $type; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, $matches[1] ]; } elseif ( preg_match( '/^TRUNCATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?' . $qt . '/i', $sql, $matches ) ) { - return $this->sessionTempTables[$matches[1]] ?? null; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, null ]; } elseif ( preg_match( '/^(?:(?:INSERT|REPLACE)\s+(?:\w+\s+)?INTO|UPDATE|DELETE\s+FROM)\s+' . $qt . '/i', $sql, $matches ) ) { - return $this->sessionTempTables[$matches[1]] ?? null; + return [ $this->sessionTempTables[$matches[1]] ?? null, null, null ]; } - return null; + return [ null, null, null ]; } - public function query( $sql, $fname = __METHOD__, $flags = 0 ) { - $this->assertTransactionStatus( $sql, $fname ); - $this->assertHasConnectionHandle(); + /** + * @param IResultWrapper|bool $ret + * @param int|null $tmpType TEMP_NORMAL or TEMP_PSEUDO_PERMANENT + * @param string|null $tmpNew Name of created temp table + * @param string|null $tmpDel Name of dropped temp table + */ + protected function registerTempWrites( $ret, $tmpType, $tmpNew, $tmpDel ) { + if ( $ret !== false ) { + if ( $tmpNew !== null ) { + $this->sessionTempTables[$tmpNew] = $tmpType; + } + if ( $tmpDel !== null ) { + unset( $this->sessionTempTables[$tmpDel] ); + } + } + } + public function query( $sql, $fname = __METHOD__, $flags = 0 ) { $flags = (int)$flags; // b/c; this field used to be a bool - $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS ); + // Sanity check that the SQL query is appropriate in the current context and is + // allowed for an outside caller (e.g. does not break transaction/session tracking). + $this->assertQueryIsCurrentlyAllowed( $sql, $fname ); - $priorTransaction = $this->trxLevel; - $priorWritesPending = $this->writesOrCallbacksPending(); - $this->lastQuery = $sql; + // Send the query to the server and fetch any corresponding errors + list( $ret, $err, $errno, $unignorable ) = $this->executeQuery( $sql, $fname, $flags ); + if ( $ret === false ) { + $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS ); + // Throw an error unless both the ignore flag was set and a rollback is not needed + $this->reportQueryError( $err, $errno, $sql, $fname, $ignoreErrors && !$unignorable ); + } + + return $this->resultObject( $ret ); + } + + /** + * Execute a query, retrying it if there is a recoverable connection loss + * + * This is similar to query() except: + * - It does not prevent all non-ROLLBACK queries if there is a corrupted transaction + * - It does not disallow raw queries that are supposed to use dedicated IDatabase methods + * - It does not throw exceptions for common error cases + * + * This is meant for internal use with Database subclasses. + * + * @param string $sql Original SQL query + * @param string $fname Name of the calling function + * @param int $flags Bitfield of class QUERY_* constants + * @return array An n-tuple of: + * - mixed|bool: An object, resource, or true on success; false on failure + * - string: The result of calling lastError() + * - int: The result of calling lastErrno() + * - bool: Whether a rollback is needed to allow future non-rollback queries + * @throws DBUnexpectedError + */ + final protected function executeQuery( $sql, $fname, $flags ) { + $this->assertHasConnectionHandle(); + + $priorTransaction = $this->trxLevel(); if ( $this->isWriteQuery( $sql ) ) { - # In theory, non-persistent writes are allowed in read-only mode, but due to things - # like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway... + // In theory, non-persistent writes are allowed in read-only mode, but due to things + // like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway... $this->assertIsWritableMaster(); - # Do not treat temporary table writes as "meaningful writes" that need committing. - # Profile them as reads. Integration tests can override this behavior via $flags. + // Do not treat temporary table writes as "meaningful writes" since they are only + // visible to one session and are not permanent. Profile them as reads. Integration + // tests can override this behavior via $flags. $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT ); - $tableType = $this->registerTempTableWrite( $sql, $pseudoPermanent ); - $isEffectiveWrite = ( $tableType !== self::TEMP_NORMAL ); - # DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries - if ( $isEffectiveWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) { + list( $tmpType, $tmpNew, $tmpDel ) = $this->getTempWrites( $sql, $pseudoPermanent ); + $isPermWrite = ( $tmpType !== self::$TEMP_NORMAL ); + // DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries + if ( $isPermWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) { throw new DBReadOnlyRoleError( $this, "Cannot write; target role is DB_REPLICA" ); } } else { - $isEffectiveWrite = false; + // No permanent writes in this query + $isPermWrite = false; + // No temporary tables written to either + list( $tmpType, $tmpNew, $tmpDel ) = [ null, null, null ]; } - # Add trace comment to the begin of the sql string, right after the operator. - # Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598) + // Add trace comment to the begin of the sql string, right after the operator. + // Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598) $commentedSql = preg_replace( '/\s|$/', " /* $fname {$this->agent} */ ", $sql, 1 ); - # Send the query to the server and fetch any corresponding errors - $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ); - $lastError = $this->lastError(); - $lastErrno = $this->lastErrno(); + // Send the query to the server and fetch any corresponding errors. + // This also doubles as a "ping" to see if the connection was dropped. + list( $ret, $err, $errno, $recoverableSR, $recoverableCL, $reconnected ) = + $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ); - $recoverableSR = false; // recoverable statement rollback? - $recoverableCL = false; // recoverable connection loss? - - if ( $ret === false && $this->wasConnectionLoss() ) { - # Check if no meaningful session state was lost - $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending ); - # Update session state tracking and try to restore the connection - $reconnected = $this->replaceLostConnection( __METHOD__ ); - # Silently resend the query to the server if it is safe and possible - if ( $recoverableCL && $reconnected ) { - $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ); - $lastError = $this->lastError(); - $lastErrno = $this->lastErrno(); - - if ( $ret === false && $this->wasConnectionLoss() ) { - # Query probably causes disconnects; reconnect and do not re-run it - $this->replaceLostConnection( __METHOD__ ); - } else { - $recoverableCL = false; // connection does not need recovering - $recoverableSR = $this->wasKnownStatementRollbackError(); - } - } - } else { - $recoverableSR = $this->wasKnownStatementRollbackError(); + // Check if the query failed due to a recoverable connection loss + $allowRetry = !$this->hasFlags( $flags, self::QUERY_NO_RETRY ); + if ( $ret === false && $recoverableCL && $reconnected && $allowRetry ) { + // Silently resend the query to the server since it is safe and possible + list( $ret, $err, $errno, $recoverableSR, $recoverableCL ) = + $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ); } + // Register creation and dropping of temporary tables + $this->registerTempWrites( $ret, $tmpType, $tmpNew, $tmpDel ); + + $corruptedTrx = false; + if ( $ret === false ) { if ( $priorTransaction ) { if ( $recoverableSR ) { # We're ignoring an error that caused just the current query to be aborted. # But log the cause so we can log a deprecation notice if a caller actually # does ignore it. - $this->trxStatusIgnoredCause = [ $lastError, $lastErrno, $fname ]; + $this->trxStatusIgnoredCause = [ $err, $errno, $fname ]; } elseif ( !$recoverableCL ) { # Either the query was aborted or all queries after BEGIN where aborted. # In the first case, the only options going forward are (a) ROLLBACK, or # (b) ROLLBACK TO SAVEPOINT (if one was set). If the later case, the only # option is ROLLBACK, since the snapshots would have been released. + $corruptedTrx = true; // cannot recover $this->trxStatus = self::STATUS_TRX_ERROR; $this->trxStatusCause = - $this->getQueryExceptionAndLog( $lastError, $lastErrno, $sql, $fname ); - $ignoreErrors = false; // cannot recover + $this->getQueryExceptionAndLog( $err, $errno, $sql, $fname ); $this->trxStatusIgnoredCause = null; } } - - $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $ignoreErrors ); } - return $this->resultObject( $ret ); + return [ $ret, $err, $errno, $corruptedTrx ]; } /** - * Wrapper for query() that also handles profiling, logging, and affected row count updates + * Wrapper for doQuery() that handles DBO_TRX, profiling, logging, affected row count + * tracking, and reconnects (without retry) on query failure due to connection loss * * @param string $sql Original SQL query * @param string $commentedSql SQL query with debugging/trace comment - * @param bool $isEffectiveWrite Whether the query is a (non-temporary table) write + * @param bool $isPermWrite Whether the query is a (non-temporary table) write * @param string $fname Name of the calling function - * @return bool|ResultWrapper True for a successful write query, ResultWrapper - * object for a successful read query, or false on failure + * @param int $flags Bitfield of class QUERY_* constants + * @return array An n-tuple of: + * - mixed|bool: An object, resource, or true on success; false on failure + * - string: The result of calling lastError() + * - int: The result of calling lastErrno() + * - bool: Whether a statement rollback error occured + * - bool: Whether a disconnect *both* happened *and* was recoverable + * - bool: Whether a reconnection attempt was *both* made *and* succeeded + * @throws DBUnexpectedError */ - private function attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ) { - $this->beginIfImplied( $sql, $fname ); + private function executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ) { + $priorWritesPending = $this->writesOrCallbacksPending(); + + if ( ( $flags & self::QUERY_IGNORE_DBO_TRX ) == 0 ) { + $this->beginIfImplied( $sql, $fname ); + } - # Keep track of whether the transaction has write queries pending - if ( $isEffectiveWrite ) { + // Keep track of whether the transaction has write queries pending + if ( $isPermWrite ) { $this->lastWriteTime = microtime( true ); - if ( $this->trxLevel && !$this->trxDoneWrites ) { + if ( $this->trxLevel() && !$this->trxDoneWrites ) { $this->trxDoneWrites = true; $this->trxProfiler->transactionWritingIn( $this->server, $this->getDomainID(), $this->trxShortId ); } } - if ( $this->getFlag( self::DBO_DEBUG ) ) { - $this->queryLogger->debug( "{$this->getDomainID()} {$commentedSql}" ); - } - - $isMaster = !is_null( $this->getLBInfo( 'master' ) ); - # generalizeSQL() will probably cut down the query to reasonable - # logging size most of the time. The substr is really just a sanity check. - if ( $isMaster ) { - $queryProf = 'query-m: ' . substr( self::generalizeSQL( $sql ), 0, 255 ); - } else { - $queryProf = 'query: ' . substr( self::generalizeSQL( $sql ), 0, 255 ); - } - - # Include query transaction state - $queryProf .= $this->trxShortId ? " [TRX#{$this->trxShortId}]" : ""; + $prefix = !is_null( $this->getLBInfo( 'master' ) ) ? 'query-m: ' : 'query: '; + $generalizedSql = new GeneralizedSql( $sql, $this->trxShortId, $prefix ); $startTime = microtime( true ); - $ps = $this->profiler ? ( $this->profiler )( $queryProf ) : null; + $ps = $this->profiler + ? ( $this->profiler )( $generalizedSql->stringify() ) + : null; $this->affectedRowCount = null; + $this->lastQuery = $sql; $ret = $this->doQuery( $commentedSql ); + $lastError = $this->lastError(); + $lastErrno = $this->lastErrno(); + $this->affectedRowCount = $this->affectedRows(); unset( $ps ); // profile out (if set) $queryRuntime = max( microtime( true ) - $startTime, 0.0 ); + $recoverableSR = false; // recoverable statement rollback? + $recoverableCL = false; // recoverable connection loss? + $reconnected = false; // reconnection both attempted and succeeded? + if ( $ret !== false ) { $this->lastPing = $startTime; - if ( $isEffectiveWrite && $this->trxLevel ) { + if ( $isPermWrite && $this->trxLevel() ) { $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() ); $this->trxWriteCallers[] = $fname; } + } elseif ( $this->wasConnectionError( $lastErrno ) ) { + # Check if no meaningful session state was lost + $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending ); + # Update session state tracking and try to restore the connection + $reconnected = $this->replaceLostConnection( __METHOD__ ); + } else { + # Check if only the last query was rolled back + $recoverableSR = $this->wasKnownStatementRollbackError(); } - if ( $sql === self::PING_QUERY ) { - $this->rttEstimate = $queryRuntime; + if ( $sql === self::$PING_QUERY ) { + $this->lastRoundTripEstimate = $queryRuntime; } $this->trxProfiler->recordQueryCompletion( - $queryProf, + $generalizedSql, $startTime, - $isEffectiveWrite, - $isEffectiveWrite ? $this->affectedRows() : $this->numRows( $ret ) + $isPermWrite, + $isPermWrite ? $this->affectedRows() : $this->numRows( $ret ) ); - $this->queryLogger->debug( $sql, [ - 'method' => $fname, - 'master' => $isMaster, - 'runtime' => $queryRuntime, - ] ); - return $ret; + // Avoid the overhead of logging calls unless debug mode is enabled + if ( $this->getFlag( self::DBO_DEBUG ) ) { + $this->queryLogger->debug( + "{method} [{runtime}s]: $sql", + [ + 'method' => $fname, + 'db_host' => $this->getServer(), + 'domain' => $this->getDomainID(), + 'runtime' => round( $queryRuntime, 3 ) + ] + ); + } + + return [ $ret, $lastError, $lastErrno, $recoverableSR, $recoverableCL, $reconnected ]; } /** @@ -1359,7 +1348,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ private function beginIfImplied( $sql, $fname ) { if ( - !$this->trxLevel && + !$this->trxLevel() && $this->getFlag( self::DBO_TRX ) && $this->isTransactableQuery( $sql ) ) { @@ -1383,13 +1372,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware private function updateTrxWriteQueryTime( $sql, $runtime, $affected ) { // Whether this is indicative of replica DB runtime (except for RBR or ws_repl) $indicativeOfReplicaRuntime = true; - if ( $runtime > self::SLOW_WRITE_SEC ) { + if ( $runtime > self::$SLOW_WRITE_SEC ) { $verb = $this->getQueryVerb( $sql ); // insert(), upsert(), replace() are fast unless bulky in size or blocked on locks if ( $verb === 'INSERT' ) { - $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS; + $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS; } elseif ( $verb === 'REPLACE' ) { - $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS / 2; + $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS / 2; } } @@ -1409,7 +1398,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @param string $fname * @throws DBTransactionStateError */ - private function assertTransactionStatus( $sql, $fname ) { + private function assertQueryIsCurrentlyAllowed( $sql, $fname ) { $verb = $this->getQueryVerb( $sql ); if ( $verb === 'USE' ) { throw new DBUnexpectedError( $this, "Got USE query; use selectDomain() instead." ); @@ -1460,7 +1449,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware # Dropped connections also mean that named locks are automatically released. # Only allow error suppression in autocommit mode or when the lost transaction # didn't matter anyway (aside from DBO_TRX snapshot loss). - if ( $this->namedLocksHeld ) { + if ( $this->sessionNamedLocks ) { return false; // possible critical section violation } elseif ( $this->sessionTempTables ) { return false; // tables might be queried latter @@ -1487,9 +1476,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->sessionTempTables = []; // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS - $this->namedLocksHeld = []; + $this->sessionNamedLocks = []; // Session loss implies transaction loss - $this->trxLevel = 0; + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxAtomicCounter = 0; $this->trxIdleCallbacks = []; // T67263; transaction already lost $this->trxPreCommitCallbacks = []; // T67263; transaction already lost @@ -1498,7 +1487,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $this->pendingWriteQueryDuration( self::ESTIMATE_TOTAL ), $this->trxWriteAffectedRows ); @@ -1524,6 +1513,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + /** + * Reset the transaction ID and return the old one + * + * @return string The old transaction ID or the empty string if there wasn't one + */ + private function consumeTrxShortId() { + $old = $this->trxShortId; + $this->trxShortId = ''; + + return $old; + } + /** * Checks whether the cause of the error is detected to be a timeout. * @@ -1546,11 +1547,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @param int $errno * @param string $sql * @param string $fname - * @param bool $ignoreErrors + * @param bool $ignore * @throws DBQueryError */ - public function reportQueryError( $error, $errno, $sql, $fname, $ignoreErrors = false ) { - if ( $ignoreErrors ) { + public function reportQueryError( $error, $errno, $sql, $fname, $ignore = false ) { + if ( $ignore ) { $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" ); } else { $exception = $this->getQueryExceptionAndLog( $error, $errno, $sql, $fname ); @@ -1580,9 +1581,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ] ) ); $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" ); - $wasQueryTimeout = $this->wasQueryTimeout( $error, $errno ); - if ( $wasQueryTimeout ) { + if ( $this->wasQueryTimeout( $error, $errno ) ) { $e = new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname ); + } elseif ( $this->wasConnectionError( $errno ) ) { + $e = new DBQueryDisconnectedError( $this, $error, $errno, $sql, $fname ); } else { $e = new DBQueryError( $this, $error, $errno, $sql, $fname ); } @@ -1607,17 +1609,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $options['LIMIT'] = 1; $res = $this->select( $table, $var, $cond, $fname, $options, $join_conds ); - if ( $res === false || !$this->numRows( $res ) ) { - return false; + if ( $res === false ) { + throw new DBUnexpectedError( $this, "Got false from select()" ); } $row = $this->fetchRow( $res ); - - if ( $row !== false ) { - return reset( $row ); - } else { + if ( $row === false ) { return false; } + + return reset( $row ); } public function selectFieldValues( @@ -1635,7 +1636,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $res = $this->select( $table, [ 'value' => $var ], $cond, $fname, $options, $join_conds ); if ( $res === false ) { - return false; + throw new DBUnexpectedError( $this, "Got false from select()" ); } $values = []; @@ -1872,19 +1873,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ) { $options = (array)$options; $options['LIMIT'] = 1; - $res = $this->select( $table, $vars, $conds, $fname, $options, $join_conds ); + $res = $this->select( $table, $vars, $conds, $fname, $options, $join_conds ); if ( $res === false ) { - return false; + throw new DBUnexpectedError( $this, "Got false from select()" ); } if ( !$this->numRows( $res ) ) { return false; } - $obj = $this->fetchObject( $res ); - - return $obj; + return $this->fetchObject( $res ); } public function estimateRowCount( @@ -2023,7 +2022,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function lockForUpdate( $table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = [] ) { - if ( !$this->trxLevel && !$this->getFlag( self::DBO_TRX ) ) { + if ( !$this->trxLevel() && !$this->getFlag( self::DBO_TRX ) ) { throw new DBUnexpectedError( $this, __METHOD__ . ': no transaction is active nor is DBO_TRX set' @@ -2036,36 +2035,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $this->selectRowCount( $table, '*', $conds, $fname, $options, $join_conds ); } - /** - * Removes most variables from an SQL query and replaces them with X or N for numbers. - * It's only slightly flawed. Don't use for anything important. - * - * @param string $sql A SQL Query - * - * @return string - */ - protected static function generalizeSQL( $sql ) { - # This does the same as the regexp below would do, but in such a way - # as to avoid crashing php on some large strings. - # $sql = preg_replace( "/'([^\\\\']|\\\\.)*'|\"([^\\\\\"]|\\\\.)*\"/", "'X'", $sql ); - - $sql = str_replace( "\\\\", '', $sql ); - $sql = str_replace( "\\'", '', $sql ); - $sql = str_replace( "\\\"", '', $sql ); - $sql = preg_replace( "/'.*'/s", "'X'", $sql ); - $sql = preg_replace( '/".*"/s', "'X'", $sql ); - - # All newlines, tabs, etc replaced by single space - $sql = preg_replace( '/\s+/', ' ', $sql ); - - # All numbers => N, - # except the ones surrounded by characters, e.g. l10n - $sql = preg_replace( '/-?\d+(,-?\d+)+/s', 'N,...,N', $sql ); - $sql = preg_replace( '/(?fieldInfo( $table, $field ); @@ -2775,11 +2744,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $s ); } - public function buildLike() { - $params = func_get_args(); - - if ( count( $params ) > 0 && is_array( $params[0] ) ) { - $params = $params[0]; + public function buildLike( $param, ...$params ) { + if ( is_array( $param ) ) { + $params = $param; + } else { + $params = func_get_args(); } $s = ''; @@ -2997,7 +2966,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function textFieldSize( $table, $field ) { $table = $this->tableName( $table ); - $sql = "SHOW COLUMNS FROM $table LIKE \"$field\";"; + $sql = "SHOW COLUMNS FROM $table LIKE \"$field\""; $res = $this->query( $sql, __METHOD__ ); $row = $this->fetchObject( $res ); @@ -3347,7 +3316,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function deadlockLoop() { $args = func_get_args(); $function = array_shift( $args ); - $tries = self::DEADLOCK_TRIES; + $tries = self::$DEADLOCK_TRIES; $this->begin( __METHOD__ ); @@ -3361,7 +3330,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } catch ( DBQueryError $e ) { if ( $this->wasDeadlock() ) { // Retry after a randomized delay - usleep( mt_rand( self::DEADLOCK_DELAY_MIN, self::DEADLOCK_DELAY_MAX ) ); + usleep( mt_rand( self::$DEADLOCK_DELAY_MIN, self::$DEADLOCK_DELAY_MAX ) ); } else { // Throw the error back up throw $e; @@ -3400,21 +3369,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { throw new DBUnexpectedError( $this, "No transaction is active." ); } $this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + if ( !$this->trxLevel() && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); $this->trxAutomatic = true; } $this->trxIdleCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE ); } } @@ -3424,13 +3393,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) { - if ( !$this->trxLevel && $this->getTransactionRoundId() ) { + if ( !$this->trxLevel() && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); $this->trxAutomatic = true; } - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { $this->trxPreCommitCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } else { // No transaction is active nor will start implicitly, so make one for this callback @@ -3445,11 +3414,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + final public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) { + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); + } + $this->trxSectionCancelCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; + } + /** * @return AtomicSectionIdentifier|null ID of the topmost atomic section level */ private function currentAtomicSectionId() { - if ( $this->trxLevel && $this->trxAtomicLevels ) { + if ( $this->trxLevel() && $this->trxAtomicLevels ) { $levelInfo = end( $this->trxAtomicLevels ); return $levelInfo[1]; @@ -3459,6 +3435,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } /** + * Hoist callback ownership for callbacks in a section to a parent section. + * All callbacks should have an owner that is present in trxAtomicLevels. * @param AtomicSectionIdentifier $old * @param AtomicSectionIdentifier $new */ @@ -3480,13 +3458,35 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxEndCallbacks[$key][2] = $new; } } + foreach ( $this->trxSectionCancelCallbacks as $key => $info ) { + if ( $info[2] === $old ) { + $this->trxSectionCancelCallbacks[$key][2] = $new; + } + } } /** + * Update callbacks that were owned by cancelled atomic sections. + * + * Callbacks for "on commit" should never be run if they're owned by a + * section that won't be committed. + * + * Callbacks for "on resolution" need to reflect that the section was + * rolled back, even if the transaction as a whole commits successfully. + * + * Callbacks for "on section cancel" should already have been consumed, + * but errors during the cancellation itself can prevent that while still + * destroying the section. Hoist any such callbacks to the new top section, + * which we assume will itself have to be cancelled or rolled back to + * resolve the error. + * * @param AtomicSectionIdentifier[] $sectionIds ID of an actual savepoint + * @param AtomicSectionIdentifier|null $newSectionId New top section ID. * @throws UnexpectedValueException */ - private function modifyCallbacksForCancel( array $sectionIds ) { + private function modifyCallbacksForCancel( + array $sectionIds, AtomicSectionIdentifier $newSectionId = null + ) { // Cancel the "on commit" callbacks owned by this savepoint $this->trxIdleCallbacks = array_filter( $this->trxIdleCallbacks, @@ -3505,8 +3505,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( in_array( $entry[2], $sectionIds, true ) ) { $callback = $entry[0]; $this->trxEndCallbacks[$key][0] = function () use ( $callback ) { + // @phan-suppress-next-line PhanInfiniteRecursion No recursion at all here, phan is confused return $callback( self::TRIGGER_ROLLBACK, $this ); }; + // This "on resolution" callback no longer belongs to a section. + $this->trxEndCallbacks[$key][2] = null; + } + } + // Hoist callback ownership for section cancel callbacks to the new top section + foreach ( $this->trxSectionCancelCallbacks as $key => $entry ) { + if ( in_array( $entry[2], $sectionIds, true ) ) { + $this->trxSectionCancelCallbacks[$key][2] = $newSectionId; } } } @@ -3542,7 +3551,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @throws Exception */ public function runOnTransactionIdleCallbacks( $trigger ) { - if ( $this->trxLevel ) { // sanity + if ( $this->trxLevel() ) { // sanity throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open.' ); } @@ -3561,6 +3570,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); $this->trxIdleCallbacks = []; // consumed (and recursion guard) $this->trxEndCallbacks = []; // consumed (recursion guard) + + // Only run trxSectionCancelCallbacks on rollback, not commit. + // But always consume them. + if ( $trigger === self::TRIGGER_ROLLBACK ) { + $callbacks = array_merge( $callbacks, $this->trxSectionCancelCallbacks ); + } + $this->trxSectionCancelCallbacks = []; // consumed (recursion guard) + foreach ( $callbacks as $callback ) { ++$count; list( $phpCallback ) = $callback; @@ -3628,6 +3645,46 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $count; } + /** + * Actually run any "atomic section cancel" callbacks. + * + * @param int $trigger IDatabase::TRIGGER_* constant + * @param AtomicSectionIdentifier[]|null $sectionIds Section IDs to cancel, + * null on transaction rollback + */ + private function runOnAtomicSectionCancelCallbacks( + $trigger, array $sectionIds = null + ) { + /** @var Exception|Throwable $e */ + $e = null; // first exception + + $notCancelled = []; + do { + $callbacks = $this->trxSectionCancelCallbacks; + $this->trxSectionCancelCallbacks = []; // consumed (recursion guard) + foreach ( $callbacks as $entry ) { + if ( $sectionIds === null || in_array( $entry[2], $sectionIds, true ) ) { + try { + $entry[0]( $trigger, $this ); + } catch ( Exception $ex ) { + ( $this->errorLogger )( $ex ); + $e = $e ?: $ex; + } catch ( Throwable $ex ) { + // @todo: Log? + $e = $e ?: $ex; + } + } else { + $notCancelled[] = $entry; + } + } + } while ( count( $this->trxSectionCancelCallbacks ) ); + $this->trxSectionCancelCallbacks = $notCancelled; + + if ( $e !== null ) { + throw $e; // re-throw any first Exception/Throwable + } + } + /** * Actually run any "transaction listener" callbacks. * @@ -3725,7 +3782,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ) { $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null; - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { $this->begin( $fname, self::TRANSACTION_INTERNAL ); // sets trxAutomatic // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result // in all changes being in one transaction to keep requests transactional. @@ -3751,7 +3808,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function endAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } @@ -3787,71 +3844,83 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware final public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ) { - if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + if ( !$this->trxLevel() || !$this->trxAtomicLevels ) { throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } - $excisedFnames = []; - if ( $sectionId !== null ) { - // Find the (last) section with the given $sectionId - $pos = -1; - foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { - if ( $asId === $sectionId ) { - $pos = $i; + $excisedIds = []; + $newTopSection = $this->currentAtomicSectionId(); + try { + $excisedFnames = []; + if ( $sectionId !== null ) { + // Find the (last) section with the given $sectionId + $pos = -1; + foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { + if ( $asId === $sectionId ) { + $pos = $i; + } } + if ( $pos < 0 ) { + throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" ); + } + // Remove all descendant sections and re-index the array + $len = count( $this->trxAtomicLevels ); + for ( $i = $pos + 1; $i < $len; ++$i ) { + $excisedFnames[] = $this->trxAtomicLevels[$i][0]; + $excisedIds[] = $this->trxAtomicLevels[$i][1]; + } + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); + $newTopSection = $this->currentAtomicSectionId(); } - if ( $pos < 0 ) { - throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" ); - } - // Remove all descendant sections and re-index the array - $excisedIds = []; - $len = count( $this->trxAtomicLevels ); - for ( $i = $pos + 1; $i < $len; ++$i ) { - $excisedFnames[] = $this->trxAtomicLevels[$i][0]; - $excisedIds[] = $this->trxAtomicLevels[$i][1]; - } - $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); - $this->modifyCallbacksForCancel( $excisedIds ); - } - // Check if the current section matches $fname - $pos = count( $this->trxAtomicLevels ) - 1; - list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; - if ( $excisedFnames ) { - $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " . - "and descendants " . implode( ', ', $excisedFnames ) ); - } else { - $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" ); - } + if ( $excisedFnames ) { + $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " . + "and descendants " . implode( ', ', $excisedFnames ) ); + } else { + $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" ); + } - if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( - $this, - "Invalid atomic section ended (got $fname but expected $savedFname)." - ); - } + if ( $savedFname !== $fname ) { + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)." + ); + } - // Remove the last section (no need to re-index the array) - array_pop( $this->trxAtomicLevels ); - $this->modifyCallbacksForCancel( [ $savedSectionId ] ); + // Remove the last section (no need to re-index the array) + array_pop( $this->trxAtomicLevels ); + $excisedIds[] = $savedSectionId; + $newTopSection = $this->currentAtomicSectionId(); - if ( $savepointId !== null ) { - // Rollback the transaction to the state just before this atomic section - if ( $savepointId === self::$NOT_APPLICABLE ) { - $this->rollback( $fname, self::FLUSHING_INTERNAL ); - } else { - $this->doRollbackToSavepoint( $savepointId, $fname ); - $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered - $this->trxStatusIgnoredCause = null; + if ( $savepointId !== null ) { + // Rollback the transaction to the state just before this atomic section + if ( $savepointId === self::$NOT_APPLICABLE ) { + $this->rollback( $fname, self::FLUSHING_INTERNAL ); + // Note: rollback() will run trxSectionCancelCallbacks + } else { + $this->doRollbackToSavepoint( $savepointId, $fname ); + $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered + $this->trxStatusIgnoredCause = null; + + // Run trxSectionCancelCallbacks now. + $this->runOnAtomicSectionCancelCallbacks( self::TRIGGER_CANCEL, $excisedIds ); + } + } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { + // Put the transaction into an error state if it's not already in one + $this->trxStatus = self::STATUS_TRX_ERROR; + $this->trxStatusCause = new DBUnexpectedError( + $this, + "Uncancelable atomic section canceled (got $fname)." + ); } - } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { - // Put the transaction into an error state if it's not already in one - $this->trxStatus = self::STATUS_TRX_ERROR; - $this->trxStatusCause = new DBUnexpectedError( - $this, - "Uncancelable atomic section canceled (got $fname)." - ); + } finally { + // Fix up callbacks owned by the sections that were just cancelled. + // All callbacks should have an owner that is present in trxAtomicLevels. + $this->modifyCallbacksForCancel( $excisedIds, $newTopSection ); } $this->affectedRowCount = 0; // for the sake of consistency @@ -3880,7 +3949,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } // Protect against mismatched atomic section, transaction nesting, and snapshot loss - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { if ( $this->trxAtomicLevels ) { $levels = $this->flatAtomicSectionList(); $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open."; @@ -3900,6 +3969,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->assertHasConnectionHandle(); $this->doBegin( $fname ); + $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) ); $this->trxStatus = self::STATUS_TRX_OK; $this->trxStatusIgnoredCause = null; $this->trxAtomicCounter = 0; @@ -3908,7 +3978,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxDoneWrites = false; $this->trxAutomaticAtomic = false; $this->trxAtomicLevels = []; - $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) ); $this->trxWriteDuration = 0.0; $this->trxWriteQueryCount = 0; $this->trxWriteAffectedRows = 0; @@ -3930,10 +3999,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::begin() * @param string $fname + * @throws DBError */ protected function doBegin( $fname ) { $this->query( 'BEGIN', $fname ); - $this->trxLevel = 1; } final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { @@ -3942,7 +4011,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." ); } - if ( $this->trxLevel && $this->trxAtomicLevels ) { + if ( $this->trxLevel() && $this->trxAtomicLevels ) { // There are still atomic sections open; this cannot be ignored $levels = $this->flatAtomicSectionList(); throw new DBUnexpectedError( @@ -3952,7 +4021,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) { - if ( !$this->trxLevel ) { + if ( !$this->trxLevel() ) { return; // nothing to do } elseif ( !$this->trxAutomatic ) { throw new DBUnexpectedError( @@ -3960,7 +4029,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware "$fname: Flushing an explicit transaction, getting out of sync." ); } - } elseif ( !$this->trxLevel ) { + } elseif ( !$this->trxLevel() ) { $this->queryLogger->error( "$fname: No transaction to commit, something got out of sync." ); return; // nothing to do @@ -3977,6 +4046,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY ); $this->doCommit( $fname ); + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxStatus = self::STATUS_TRX_NONE; if ( $this->trxDoneWrites ) { @@ -3984,7 +4054,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $writeTime, $this->trxWriteAffectedRows ); @@ -4002,16 +4072,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::commit() * @param string $fname + * @throws DBError */ protected function doCommit( $fname ) { - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { $this->query( 'COMMIT', $fname ); - $this->trxLevel = 0; } } - final public function rollback( $fname = __METHOD__, $flush = '' ) { - $trxActive = $this->trxLevel; + final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { + $trxActive = $this->trxLevel(); if ( $flush !== self::FLUSHING_INTERNAL && $flush !== self::FLUSHING_ALL_PEERS @@ -4027,6 +4097,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->assertHasConnectionHandle(); $this->doRollback( $fname ); + $oldTrxShortId = $this->consumeTrxShortId(); $this->trxStatus = self::STATUS_TRX_NONE; $this->trxAtomicLevels = []; // Estimate the RTT via a query now that trxStatus is OK @@ -4036,7 +4107,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxProfiler->transactionWritingOut( $this->server, $this->getDomainID(), - $this->trxShortId, + $oldTrxShortId, $writeTime, $this->trxWriteAffectedRows ); @@ -4070,13 +4141,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * * @see Database::rollback() * @param string $fname + * @throws DBError */ protected function doRollback( $fname ) { - if ( $this->trxLevel ) { + if ( $this->trxLevel() ) { # Disconnects cause rollback anyway, so ignore those errors $ignoreErrors = true; $this->query( 'ROLLBACK', $fname, $ignoreErrors ); - $this->trxLevel = 0; } } @@ -4094,7 +4165,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function explicitTrxActive() { - return $this->trxLevel && ( $this->trxAtomicLevels || !$this->trxAutomatic ); + return $this->trxLevel() && ( $this->trxAtomicLevels || !$this->trxAutomatic ); } public function duplicateTableStructure( @@ -4137,26 +4208,24 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware abstract protected function fetchAffectedRowCount(); /** - * Take the result from a query, and wrap it in a ResultWrapper if - * necessary. Boolean values are passed through as is, to indicate success - * of write queries or failure. + * Take a query result and wrap it in an iterable result wrapper if necessary. + * Booleans are passed through as-is to indicate success/failure of write queries. * * Once upon a time, Database::query() returned a bare MySQL result * resource, and it was necessary to call this function to convert it to * a wrapper. Nowadays, raw database objects are never exposed to external * callers, so this is unnecessary in external code. * - * @param bool|ResultWrapper|resource $result - * @return bool|ResultWrapper + * @param bool|IResultWrapper|resource $result + * @return bool|IResultWrapper */ protected function resultObject( $result ) { if ( !$result ) { - return false; - } elseif ( $result instanceof ResultWrapper ) { + return false; // failed query + } elseif ( $result instanceof IResultWrapper ) { return $result; } elseif ( $result === true ) { - // Successful write query - return $result; + return $result; // succesful write query } else { return new ResultWrapper( $this, $result ); } @@ -4164,20 +4233,20 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware public function ping( &$rtt = null ) { // Avoid hitting the server if it was hit recently - if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) { - if ( !func_num_args() || $this->rttEstimate > 0 ) { - $rtt = $this->rttEstimate; + if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::$PING_TTL ) { + if ( !func_num_args() || $this->lastRoundTripEstimate > 0 ) { + $rtt = $this->lastRoundTripEstimate; return true; // don't care about $rtt } } // This will reconnect if possible or return false if not $this->clearFlag( self::DBO_TRX, self::REMEMBER_PRIOR ); - $ok = ( $this->query( self::PING_QUERY, __METHOD__, true ) !== false ); + $ok = ( $this->query( self::$PING_QUERY, __METHOD__, true ) !== false ); $this->restoreFlags( self::RESTORE_PRIOR ); if ( $ok ) { - $rtt = $this->rttEstimate; + $rtt = $this->lastRoundTripEstimate; } return $ok; @@ -4191,7 +4260,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware */ protected function replaceLostConnection( $fname ) { $this->closeConnection(); - $this->opened = false; $this->conn = false; $this->handleSessionLossPreconnect(); @@ -4247,7 +4315,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * @since 1.27 */ final protected function getRecordedTransactionLagStatus() { - return ( $this->trxLevel && $this->trxReplicaLag !== null ) + return ( $this->trxLevel() && $this->trxReplicaLag !== null ) ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ] : null; } @@ -4302,6 +4370,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function getLag() { + if ( $this->getLBInfo( 'master' ) ) { + return 0; // this is the master + } elseif ( $this->getLBInfo( 'is static' ) ) { + return 0; // static dataset + } + + return $this->doGetLag(); + } + + protected function doGetLag() { return 0; } @@ -4531,17 +4609,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware // RDBMs methods for checking named locks may or may not count this thread itself. // In MySQL, IS_FREE_LOCK() returns 0 if the thread already has the lock. This is // the behavior choosen by the interface for this method. - return !isset( $this->namedLocksHeld[$lockName] ); + return !isset( $this->sessionNamedLocks[$lockName] ); } public function lock( $lockName, $method, $timeout = 5 ) { - $this->namedLocksHeld[$lockName] = 1; + $this->sessionNamedLocks[$lockName] = 1; return true; } public function unlock( $lockName, $method ) { - unset( $this->namedLocksHeld[$lockName] ); + unset( $this->sessionNamedLocks[$lockName] ); return true; } @@ -4637,7 +4715,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * Delete a table * @param string $tableName * @param string $fName - * @return bool|ResultWrapper + * @return bool|IResultWrapper * @since 1.18 */ public function dropTable( $tableName, $fName = __METHOD__ ) { @@ -4723,12 +4801,24 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $this->conn; } - /** - * @since 1.19 - * @return string - */ public function __toString() { - return (string)$this->conn; + // spl_object_id is PHP >= 7.2 + $id = function_exists( 'spl_object_id' ) + ? spl_object_id( $this ) + : spl_object_hash( $this ); + + $description = $this->getType() . ' object #' . $id; + if ( is_resource( $this->conn ) ) { + $description .= ' (' . (string)$this->conn . ')'; // "resource id #" + } elseif ( is_object( $this->conn ) ) { + // spl_object_id is PHP >= 7.2 + $handleId = function_exists( 'spl_object_id' ) + ? spl_object_id( $this->conn ) + : spl_object_hash( $this->conn ); + $description .= " (handle id #$handleId)"; + } + + return $description; } /** @@ -4743,9 +4833,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $this->isOpen() ) { // Open a new connection resource without messing with the old one - $this->opened = false; $this->conn = false; $this->trxEndCallbacks = []; // don't copy + $this->trxSectionCancelCallbacks = []; // don't copy $this->handleSessionLossPreconnect(); // no trx or locks anymore $this->open( $this->server, @@ -4773,7 +4863,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * Run a few simple sanity checks and close dangling connections */ public function __destruct() { - if ( $this->trxLevel && $this->trxDoneWrites ) { + if ( $this->trxLevel() && $this->trxDoneWrites ) { trigger_error( "Uncommitted DB writes (transaction from {$this->trxFname})." ); } @@ -4790,7 +4880,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->closeConnection(); Wikimedia\restoreWarnings(); $this->conn = false; - $this->opened = false; } } } diff --git a/includes/libs/rdbms/database/DatabaseMssql.php b/includes/libs/rdbms/database/DatabaseMssql.php index 6d266ae3ba..69174f96fa 100644 --- a/includes/libs/rdbms/database/DatabaseMssql.php +++ b/includes/libs/rdbms/database/DatabaseMssql.php @@ -27,9 +27,10 @@ namespace Wikimedia\Rdbms; -use Wikimedia; use Exception; +use RuntimeException; use stdClass; +use Wikimedia\AtEase\AtEase; /** * @ingroup Database @@ -78,7 +79,7 @@ class DatabaseMssql extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Test for driver support, to avoid suppressed fatal error + // Test for driver support, to avoid suppressed fatal error if ( !function_exists( 'sqlsrv_connect' ) ) { throw new DBConnectionError( $this, @@ -87,11 +88,6 @@ class DatabaseMssql extends Database { ); } - # e.g. the class is being loaded - if ( !strlen( $user ) ) { - return null; - } - $this->close(); $this->server = $server; $this->user = $user; @@ -110,15 +106,19 @@ class DatabaseMssql extends Database { $connectionInfo['PWD'] = $password; } - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $this->conn = sqlsrv_connect( $server, $connectionInfo ); - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); if ( $this->conn === false ) { - throw new DBConnectionError( $this, $this->lastError() ); + $error = $this->lastError(); + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); } - $this->opened = true; $this->currentDomain = new DatabaseDomain( ( $dbName != '' ) ? $dbName : null, null, @@ -229,11 +229,7 @@ class DatabaseMssql extends Database { } public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - sqlsrv_free_stmt( $res ); + sqlsrv_free_stmt( ResultWrapper::unwrap( $res ) ); } /** @@ -258,12 +254,9 @@ class DatabaseMssql extends Database { * @return int */ public function numRows( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } + $res = ResultWrapper::unwrap( $res ); $ret = sqlsrv_num_rows( $res ); - if ( $ret === false ) { // we cannot get an amount of rows from this cursor type // has_rows returns bool true/false if the result has rows @@ -278,11 +271,7 @@ class DatabaseMssql extends Database { * @return int */ public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return sqlsrv_num_fields( $res ); + return sqlsrv_num_fields( ResultWrapper::unwrap( $res ) ); } /** @@ -291,11 +280,7 @@ class DatabaseMssql extends Database { * @return int */ public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return sqlsrv_field_metadata( $res )[$n]['Name']; + return sqlsrv_field_metadata( ResultWrapper::unwrap( $res ) )[$n]['Name']; } /** @@ -375,6 +360,17 @@ class DatabaseMssql extends Database { return $statementOnly; } + public function serverIsReadOnly() { + $encDatabase = $this->addQuotes( $this->getDBname() ); + $res = $this->query( + "SELECT IS_READ_ONLY FROM SYS.DATABASES WHERE NAME = $encDatabase", + __METHOD__ + ); + $row = $this->fetchObject( $res ); + + return $row ? (bool)$row->IS_READ_ONLY : false; + } + /** * @return int */ @@ -719,7 +715,7 @@ class DatabaseMssql extends Database { } $this->scrollableCursor = true; - if ( $ret instanceof ResultWrapper && !is_null( $identity ) ) { + if ( $ret instanceof IResultWrapper && !is_null( $identity ) ) { // Then we want to get the identity column value we were assigned and save it off $row = $ret->fetchObject(); if ( is_object( $row ) ) { @@ -1072,13 +1068,10 @@ class DatabaseMssql extends Database { $this->query( 'ROLLBACK TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname ); } - /** - * Begin a transaction, committing any previously open transaction - * @param string $fname - */ protected function doBegin( $fname = __METHOD__ ) { - sqlsrv_begin_transaction( $this->conn ); - $this->trxLevel = 1; + if ( !sqlsrv_begin_transaction( $this->conn ) ) { + $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'BEGIN', $fname ); + } } /** @@ -1086,8 +1079,9 @@ class DatabaseMssql extends Database { * @param string $fname */ protected function doCommit( $fname = __METHOD__ ) { - sqlsrv_commit( $this->conn ); - $this->trxLevel = 0; + if ( !sqlsrv_commit( $this->conn ) ) { + $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'COMMIT', $fname ); + } } /** @@ -1096,8 +1090,17 @@ class DatabaseMssql extends Database { * @param string $fname */ protected function doRollback( $fname = __METHOD__ ) { - sqlsrv_rollback( $this->conn ); - $this->trxLevel = 0; + if ( !sqlsrv_rollback( $this->conn ) ) { + $this->queryLogger->error( + "{fname}\t{db_server}\t{errno}\t{error}\t", + $this->getLogContext( [ + 'errno' => $this->lastErrno(), + 'error' => $this->lastError(), + 'fname' => $fname, + 'trace' => ( new RuntimeException() )->getTraceAsString() + ] ) + ); + } } /** @@ -1167,10 +1170,13 @@ class DatabaseMssql extends Database { $database = $domain->getDatabase(); if ( $database !== $this->getDBname() ) { - $encDatabase = $this->addIdentifierQuotes( $database ); - $res = $this->doQuery( "USE $encDatabase" ); - if ( !$res ) { - throw new DBExpectedError( $this, "Could not select database '$database'." ); + $sql = 'USE ' . $this->addIdentifierQuotes( $database ); + list( $res, $err, $errno ) = + $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); + + if ( $res === false ) { + $this->reportQueryError( $err, $errno, $sql, __METHOD__ ); + return false; // unreachable } } // Update that domain fields on success (no exception thrown) @@ -1358,7 +1364,7 @@ class DatabaseMssql extends Database { * Delete a table * @param string $tableName * @param string $fName - * @return bool|ResultWrapper + * @return bool|IResultWrapper * @since 1.18 */ public function dropTable( $tableName, $fName = __METHOD__ ) { diff --git a/includes/libs/rdbms/database/DatabaseMysqlBase.php b/includes/libs/rdbms/database/DatabaseMysqlBase.php index 36c947f6eb..417b464b42 100644 --- a/includes/libs/rdbms/database/DatabaseMysqlBase.php +++ b/includes/libs/rdbms/database/DatabaseMysqlBase.php @@ -122,9 +122,12 @@ abstract class DatabaseMysqlBase extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Close/unset connection handle $this->close(); + if ( $schema !== null ) { + throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." ); + } + $this->server = $server; $this->user = $user; $this->password = $password; @@ -143,88 +146,52 @@ abstract class DatabaseMysqlBase extends Database { $error = $error ?: $this->lastError(); $this->connLogger->error( "Error connecting to {db_server}: {error}", - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $error, - ] ) + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) ); $this->connLogger->debug( "DB connection error\n" . "Server: $server, User: $user, Password: " . substr( $password, 0, 3 ) . "..., error: " . $error . "\n" ); - throw new DBConnectionError( $this, $error ); } - if ( strlen( $dbName ) ) { - $this->selectDomain( new DatabaseDomain( $dbName, null, $tablePrefix ) ); - } else { - $this->currentDomain = new DatabaseDomain( null, null, $tablePrefix ); - } - - // Tell the server what we're communicating with - if ( !$this->connectInitCharset() ) { - $error = $this->lastError(); - $this->queryLogger->error( - "Error setting character set: {error}", - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $this->lastError(), - ] ) + try { + $this->currentDomain = new DatabaseDomain( + strlen( $dbName ) ? $dbName : null, + null, + $tablePrefix ); - throw new DBConnectionError( $this, "Error setting character set: $error" ); - } - // Abstract over any insane MySQL defaults - $set = [ 'group_concat_max_len = 262144' ]; - // Set SQL mode, default is turning them all off, can be overridden or skipped with null - if ( is_string( $this->sqlMode ) ) { - $set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode ); - } - // Set any custom settings defined by site config - // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html) - foreach ( $this->sessionVars as $var => $val ) { - // Escape strings but not numbers to avoid MySQL complaining - if ( !is_int( $val ) && !is_float( $val ) ) { - $val = $this->addQuotes( $val ); + // Abstract over any insane MySQL defaults + $set = [ 'group_concat_max_len = 262144' ]; + // Set SQL mode, default is turning them all off, can be overridden or skipped with null + if ( is_string( $this->sqlMode ) ) { + $set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode ); + } + // Set any custom settings defined by site config + // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html) + foreach ( $this->connectionVariables as $var => $val ) { + // Escape strings but not numbers to avoid MySQL complaining + if ( !is_int( $val ) && !is_float( $val ) ) { + $val = $this->addQuotes( $val ); + } + $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val; } - $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val; - } - if ( $set ) { - // Use doQuery() to avoid opening implicit transactions (DBO_TRX) - $success = $this->doQuery( 'SET ' . implode( ', ', $set ) ); - if ( !$success ) { - $error = $this->lastError(); - $this->queryLogger->error( - 'Error setting MySQL variables on server {db_server}: {error}', - $this->getLogContext( [ - 'method' => __METHOD__, - 'error' => $error, - ] ) + if ( $set ) { + $this->query( + 'SET ' . implode( ', ', $set ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY ); - throw new DBConnectionError( $this, "Error setting MySQL variables: $error" ); } + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; } - $this->opened = true; - return true; } - /** - * Set the character set information right after connection - * @return bool - */ - protected function connectInitCharset() { - if ( $this->utf8Mode ) { - // Tell the server we're communicating with it in UTF-8. - // This may engage various charset conversions. - return $this->mysqlSetCharset( 'utf8' ); - } else { - return $this->mysqlSetCharset( 'binary' ); - } - } - protected function doSelectDomain( DatabaseDomain $domain ) { if ( $domain->getSchema() !== null ) { throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." ); @@ -244,11 +211,12 @@ abstract class DatabaseMysqlBase extends Database { if ( $database !== $this->getDBname() ) { $sql = 'USE ' . $this->addIdentifierQuotes( $database ); - $ret = $this->doQuery( $sql ); - if ( $ret === false ) { - $error = $this->lastError(); - $errno = $this->lastErrno(); - $this->reportQueryError( $error, $errno, $sql, __METHOD__ ); + list( $res, $err, $errno ) = + $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); + + if ( $res === false ) { + $this->reportQueryError( $err, $errno, $sql, __METHOD__ ); + return false; // unreachable } } @@ -269,23 +237,12 @@ abstract class DatabaseMysqlBase extends Database { abstract protected function mysqlConnect( $realServer, $dbName ); /** - * Set the character set of the MySQL link - * - * @param string $charset - * @return bool - */ - abstract protected function mysqlSetCharset( $charset ); - - /** - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @throws DBUnexpectedError */ public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $ok = $this->mysqlFreeResult( $res ); + $ok = $this->mysqlFreeResult( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); if ( !$ok ) { throw new DBUnexpectedError( $this, "Unable to free MySQL result" ); @@ -301,16 +258,13 @@ abstract class DatabaseMysqlBase extends Database { abstract protected function mysqlFreeResult( $res ); /** - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @return stdClass|bool * @throws DBUnexpectedError */ public function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $row = $this->mysqlFetchObject( $res ); + $row = $this->mysqlFetchObject( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); $errno = $this->lastErrno(); @@ -342,11 +296,8 @@ abstract class DatabaseMysqlBase extends Database { * @throws DBUnexpectedError */ public function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $row = $this->mysqlFetchArray( $res ); + $row = $this->mysqlFetchArray( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); $errno = $this->lastErrno(); @@ -368,22 +319,23 @@ abstract class DatabaseMysqlBase extends Database { * Fetch a result row as an associative and numeric array * * @param resource $res Raw result - * @return array + * @return array|false */ abstract protected function mysqlFetchArray( $res ); /** * @throws DBUnexpectedError - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @return int */ function numRows( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; + if ( is_bool( $res ) ) { + $n = 0; + } else { + Wikimedia\suppressWarnings(); + $n = $this->mysqlNumRows( ResultWrapper::unwrap( $res ) ); + Wikimedia\restoreWarnings(); } - Wikimedia\suppressWarnings(); - $n = !is_bool( $res ) ? $this->mysqlNumRows( $res ) : 0; - Wikimedia\restoreWarnings(); // Unfortunately, mysql_num_rows does not reset the last errno. // We are not checking for any errors here, since @@ -402,15 +354,11 @@ abstract class DatabaseMysqlBase extends Database { abstract protected function mysqlNumRows( $res ); /** - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @return int */ public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlNumFields( $res ); + return $this->mysqlNumFields( ResultWrapper::unwrap( $res ) ); } /** @@ -422,22 +370,18 @@ abstract class DatabaseMysqlBase extends Database { abstract protected function mysqlNumFields( $res ); /** - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $n * @return string */ public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlFieldName( $res, $n ); + return $this->mysqlFieldName( ResultWrapper::unwrap( $res ), $n ); } /** * Get the name of the specified field in a result * - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $n * @return string */ @@ -445,44 +389,36 @@ abstract class DatabaseMysqlBase extends Database { /** * mysql_field_type() wrapper - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $n * @return string */ public function fieldType( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlFieldType( $res, $n ); + return $this->mysqlFieldType( ResultWrapper::unwrap( $res ), $n ); } /** * Get the type of the specified field in a result * - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $n * @return string */ abstract protected function mysqlFieldType( $res, $n ); /** - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $row * @return bool */ public function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return $this->mysqlDataSeek( $res, $row ); + return $this->mysqlDataSeek( ResultWrapper::unwrap( $res ), $row ); } /** * Move internal result pointer * - * @param ResultWrapper|resource $res + * @param IResultWrapper|resource $res * @param int $row * @return bool */ @@ -641,13 +577,14 @@ abstract class DatabaseMysqlBase extends Database { */ public function fieldInfo( $table, $field ) { $table = $this->tableName( $table ); - $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, true ); + $flags = self::QUERY_SILENCE_ERRORS; + $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, $flags ); if ( !$res ) { return false; } - $n = $this->mysqlNumFields( $res->result ); + $n = $this->mysqlNumFields( ResultWrapper::unwrap( $res ) ); for ( $i = 0; $i < $n; $i++ ) { - $meta = $this->mysqlFetchField( $res->result, $i ); + $meta = $this->mysqlFetchField( ResultWrapper::unwrap( $res ), $i ); if ( $field == $meta->name ) { return new MySQLField( $meta ); } @@ -743,7 +680,7 @@ abstract class DatabaseMysqlBase extends Database { return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`'; } - public function getLag() { + protected function doGetLag() { if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) { return $this->getLagFromPtHeartbeat(); } else { @@ -762,7 +699,8 @@ abstract class DatabaseMysqlBase extends Database { * @return bool|int */ protected function getLagFromSlaveStatus() { - $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ ); + $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__, $flags ); $row = $res ? $res->fetchObject() : false; // If the server is not replicating, there will be no row if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) { @@ -864,7 +802,8 @@ abstract class DatabaseMysqlBase extends Database { // Connect to and query the master; catch errors to avoid outages try { - $res = $conn->query( 'SELECT @@server_id AS id', $fname ); + $flags = self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX; + $res = $conn->query( 'SELECT @@server_id AS id', $fname, $flags ); $row = $res ? $res->fetchObject() : false; $id = $row ? (int)$row->id : 0; } catch ( DBError $e ) { @@ -894,7 +833,8 @@ abstract class DatabaseMysqlBase extends Database { // percision field is not supported in MySQL <= 5.5. $res = $this->query( "SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1", - __METHOD__ + __METHOD__, + self::QUERY_SILENCE_ERRORS | self::QUERY_IGNORE_DBO_TRX ); $row = $res ? $res->fetchObject() : false; } finally { @@ -952,21 +892,22 @@ abstract class DatabaseMysqlBase extends Database { $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) ); if ( strpos( $gtidArg, ':' ) !== false ) { // MySQL GTIDs, e.g "source_id:transaction_id" - $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" ); + $sql = "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)"; } else { // MariaDB GTIDs, e.g."domain:server:sequence" - $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + $sql = "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)"; } } else { // Wait on the binlog coordinates $encFile = $this->addQuotes( $pos->getLogFile() ); $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] ); - $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" ); + $sql = "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)"; } + list( $res, $err ) = $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $res ? $this->fetchRow( $res ) : false; if ( !$row ) { - throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" ); + throw new DBExpectedError( $this, "Replication wait failed: {$err}" ); } // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual @@ -1071,7 +1012,9 @@ abstract class DatabaseMysqlBase extends Database { $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ), self::SERVER_ID_CACHE_TTL, function () use ( $fname ) { - $res = $this->query( "SELECT @@server_id AS id", $fname ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT @@server_id AS id", $fname, $flags ); + return intval( $this->fetchObject( $res )->id ); } ); @@ -1081,11 +1024,13 @@ abstract class DatabaseMysqlBase extends Database { * @return string|null */ protected function getServerUUID() { + $fname = __METHOD__; return $this->srvCache->getWithSetCallback( $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ), self::SERVER_ID_CACHE_TTL, - function () { - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" ); + function () use ( $fname ) { + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'", $fname, $flags ); $row = $this->fetchObject( $res ); return $row ? $row->Value : null; @@ -1099,13 +1044,15 @@ abstract class DatabaseMysqlBase extends Database { */ protected function getServerGTIDs( $fname = __METHOD__ ) { $map = []; + + $flags = self::QUERY_IGNORE_DBO_TRX; // Get global-only variables like gtid_executed - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname ); + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname, $flags ); foreach ( $res as $row ) { $map[$row->Variable_name] = $row->Value; } // Get session-specific (e.g. gtid_domain_id since that is were writes will log) - $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname ); + $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname, $flags ); foreach ( $res as $row ) { $map[$row->Variable_name] = $row->Value; } @@ -1119,11 +1066,14 @@ abstract class DatabaseMysqlBase extends Database { * @return string[] Latest available server status row */ protected function getServerRoleStatus( $role, $fname = __METHOD__ ) { - return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: []; + $flags = self::QUERY_IGNORE_DBO_TRX; + + return $this->query( "SHOW $role STATUS", $fname, $flags )->fetchRow() ?: []; } public function serverIsReadOnly() { - $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__ ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__, $flags ); $row = $this->fetchObject( $res ); return $row ? ( strtolower( $row->Value ) === 'on' ) : false; @@ -1188,9 +1138,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function setSessionOptions( array $options ) { if ( isset( $options['connTimeout'] ) ) { + $flags = self::QUERY_IGNORE_DBO_TRX; $timeout = (int)$options['connTimeout']; - $this->query( "SET net_read_timeout=$timeout" ); - $this->query( "SET net_write_timeout=$timeout" ); + $this->query( "SET net_read_timeout=$timeout", __METHOD__, $flags ); + $this->query( "SET net_write_timeout=$timeout", __METHOD__, $flags ); } } @@ -1223,8 +1174,10 @@ abstract class DatabaseMysqlBase extends Database { } $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); return ( $row->lockstatus == 1 ); } @@ -1237,8 +1190,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function lock( $lockName, $method, $timeout = 5 ) { $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); if ( $row->lockstatus == 1 ) { parent::lock( $lockName, $method, $timeout ); // record @@ -1260,8 +1215,10 @@ abstract class DatabaseMysqlBase extends Database { */ public function unlock( $lockName, $method ) { $encName = $this->addQuotes( $this->makeLockName( $lockName ) ); - $result = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method ); - $row = $this->fetchObject( $result ); + + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method, $flags ); + $row = $this->fetchObject( $res ); if ( $row->lockstatus == 1 ) { parent::unlock( $lockName, $method ); // record @@ -1297,13 +1254,13 @@ abstract class DatabaseMysqlBase extends Database { } $sql = "LOCK TABLES " . implode( ',', $items ); - $this->query( $sql, $method ); + $this->query( $sql, $method, self::QUERY_IGNORE_DBO_TRX ); return true; } protected function doUnlockTables( $method ) { - $this->query( "UNLOCK TABLES", $method ); + $this->query( "UNLOCK TABLES", $method, self::QUERY_IGNORE_DBO_TRX ); return true; } @@ -1324,7 +1281,7 @@ abstract class DatabaseMysqlBase extends Database { (bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ ); } $encValue = $value ? '1' : '0'; - $this->query( "SET sql_big_selects=$encValue", __METHOD__ ); + $this->query( "SET sql_big_selects=$encValue", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); } /** @@ -1490,7 +1447,7 @@ abstract class DatabaseMysqlBase extends Database { /** * @param string $tableName * @param string $fName - * @return bool|ResultWrapper + * @return bool|IResultWrapper */ public function dropTable( $tableName, $fName = __METHOD__ ) { if ( !$this->tableExists( $tableName, $fName ) ) { @@ -1507,7 +1464,8 @@ abstract class DatabaseMysqlBase extends Database { * @return array */ private function getMysqlStatus( $which = "%" ) { - $res = $this->query( "SHOW STATUS LIKE '{$which}'" ); + $flags = self::QUERY_IGNORE_DBO_TRX; + $res = $this->query( "SHOW STATUS LIKE '{$which}'", __METHOD__, $flags ); $status = []; foreach ( $res as $row ) { diff --git a/includes/libs/rdbms/database/DatabaseMysqli.php b/includes/libs/rdbms/database/DatabaseMysqli.php index 1a5cdab78d..0f444cd210 100644 --- a/includes/libs/rdbms/database/DatabaseMysqli.php +++ b/includes/libs/rdbms/database/DatabaseMysqli.php @@ -125,21 +125,6 @@ class DatabaseMysqli extends DatabaseMysqlBase { return false; } - protected function connectInitCharset() { - // already done in mysqlConnect() - return true; - } - - /** - * @param string $charset - * @return bool - */ - protected function mysqlSetCharset( $charset ) { - $conn = $this->getBindingHandle(); - - return $conn->set_charset( $charset ); - } - /** * @return bool */ @@ -203,7 +188,7 @@ class DatabaseMysqli extends DatabaseMysqlBase { /** * @param mysqli_result $res - * @return bool + * @return array|false */ protected function mysqlFetchArray( $res ) { $array = $res->fetch_array(); @@ -307,21 +292,6 @@ class DatabaseMysqli extends DatabaseMysqlBase { return $conn->real_escape_string( (string)$s ); } - /** - * Give an id for the connection - * - * mysql driver used resource id, but mysqli objects cannot be cast to string. - * @return string - */ - public function __toString() { - if ( $this->conn instanceof mysqli ) { - return (string)$this->conn->thread_id; - } else { - // mConn might be false or something. - return (string)$this->conn; - } - } - /** * @return mysqli */ diff --git a/includes/libs/rdbms/database/DatabasePostgres.php b/includes/libs/rdbms/database/DatabasePostgres.php index d8be62f1f4..08987d98dd 100644 --- a/includes/libs/rdbms/database/DatabasePostgres.php +++ b/includes/libs/rdbms/database/DatabasePostgres.php @@ -87,7 +87,7 @@ class DatabasePostgres extends Database { } protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) { - # Test for Postgres support, to avoid suppressed fatal error + // Test for Postgres support, to avoid suppressed fatal error if ( !function_exists( 'pg_connect' ) ) { throw new DBConnectionError( $this, @@ -143,23 +143,36 @@ class DatabasePostgres extends Database { throw new DBConnectionError( $this, str_replace( "\n", ' ', $phpError ) ); } - $this->opened = true; + try { + // If called from the command-line (e.g. importDump), only show errors. + // No transaction should be open at this point, so the problem of the SET + // effects being rolled back should not be an issue. + // See https://www.postgresql.org/docs/8.3/sql-set.html + $variables = []; + if ( $this->cliMode ) { + $variables['client_min_messages'] = 'ERROR'; + } + $variables += [ + 'client_encoding' => 'UTF8', + 'datestyle' => 'ISO, YMD', + 'timezone' => 'GMT', + 'standard_conforming_strings' => 'on', + 'bytea_output' => 'escape' + ]; + foreach ( $variables as $var => $val ) { + $this->query( + 'SET ' . $this->addIdentifierQuotes( $var ) . ' = ' . $this->addQuotes( $val ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY + ); + } - # If called from the command-line (e.g. importDump), only show errors - if ( $this->cliMode ) { - $this->doQuery( "SET client_min_messages = 'ERROR'" ); + $this->determineCoreSchema( $schema ); + $this->currentDomain = new DatabaseDomain( $dbName, $schema, $tablePrefix ); + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; } - - $this->query( "SET client_encoding='UTF8'", __METHOD__ ); - $this->query( "SET datestyle = 'ISO, YMD'", __METHOD__ ); - $this->query( "SET timezone = 'GMT'", __METHOD__ ); - $this->query( "SET standard_conforming_strings = on", __METHOD__ ); - $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127 - - $this->determineCoreSchema( $schema ); - $this->currentDomain = new DatabaseDomain( $dbName, $schema, $tablePrefix ); - - return (bool)$this->conn; } protected function relationSchemaQualifier() { @@ -261,11 +274,8 @@ class DatabasePostgres extends Database { } public function freeResult( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $ok = pg_free_result( $res ); + $ok = pg_free_result( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); if ( !$ok ) { throw new DBUnexpectedError( $this, "Unable to free Postgres result\n" ); @@ -273,11 +283,8 @@ class DatabasePostgres extends Database { } public function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $row = pg_fetch_object( $res ); + $row = pg_fetch_object( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); # @todo FIXME: HACK HACK HACK HACK debug @@ -295,11 +302,8 @@ class DatabasePostgres extends Database { } public function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $row = pg_fetch_array( $res ); + $row = pg_fetch_array( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); $conn = $this->getBindingHandle(); @@ -318,11 +322,8 @@ class DatabasePostgres extends Database { return 0; } - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } Wikimedia\suppressWarnings(); - $n = pg_num_rows( $res ); + $n = pg_num_rows( ResultWrapper::unwrap( $res ) ); Wikimedia\restoreWarnings(); $conn = $this->getBindingHandle(); @@ -337,19 +338,11 @@ class DatabasePostgres extends Database { } public function numFields( $res ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_num_fields( $res ); + return pg_num_fields( ResultWrapper::unwrap( $res ) ); } public function fieldName( $res, $n ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_field_name( $res, $n ); + return pg_field_name( ResultWrapper::unwrap( $res ), $n ); } public function insertId() { @@ -359,11 +352,7 @@ class DatabasePostgres extends Database { } public function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_result_seek( $res, $row ); + return pg_result_seek( ResultWrapper::unwrap( $res ), $row ); } public function lastError() { @@ -981,7 +970,7 @@ __INDEXATTR__; * @return string Default schema for the current session */ public function getCurrentSchema() { - $res = $this->query( "SELECT current_schema()", __METHOD__ ); + $res = $this->query( "SELECT current_schema()", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $this->fetchRow( $res ); return $row[0]; @@ -998,7 +987,11 @@ __INDEXATTR__; * @return array List of actual schemas for the current sesson */ public function getSchemas() { - $res = $this->query( "SELECT current_schemas(false)", __METHOD__ ); + $res = $this->query( + "SELECT current_schemas(false)", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); $row = $this->fetchRow( $res ); $schemas = []; @@ -1017,7 +1010,7 @@ __INDEXATTR__; * @return array How to search for table names schemas for the current user */ public function getSearchPath() { - $res = $this->query( "SHOW search_path", __METHOD__ ); + $res = $this->query( "SHOW search_path", __METHOD__, self::QUERY_IGNORE_DBO_TRX ); $row = $this->fetchRow( $res ); /* PostgreSQL returns SHOW values as strings */ @@ -1033,7 +1026,11 @@ __INDEXATTR__; * @param array $search_path List of schemas to be searched by default */ private function setSearchPath( $search_path ) { - $this->query( "SET search_path = " . implode( ", ", $search_path ) ); + $this->query( + "SET search_path = " . implode( ", ", $search_path ), + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); } /** @@ -1051,7 +1048,15 @@ __INDEXATTR__; * @param string $desiredSchema */ public function determineCoreSchema( $desiredSchema ) { - $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); + if ( $this->trxLevel() ) { + // We do not want the schema selection to change on ROLLBACK or INSERT SELECT. + // See https://www.postgresql.org/docs/8.3/sql-set.html + throw new DBUnexpectedError( + $this, + __METHOD__ . ": a transaction is currently active." + ); + } + if ( $this->schemaExists( $desiredSchema ) ) { if ( in_array( $desiredSchema, $this->getSchemas() ) ) { $this->coreSchema = $desiredSchema; @@ -1064,8 +1069,7 @@ __INDEXATTR__; * Fixes T17816 */ $search_path = $this->getSearchPath(); - array_unshift( $search_path, - $this->addIdentifierQuotes( $desiredSchema ) ); + array_unshift( $search_path, $this->addIdentifierQuotes( $desiredSchema ) ); $this->setSearchPath( $search_path ); $this->coreSchema = $desiredSchema; $this->queryLogger->debug( @@ -1077,8 +1081,6 @@ __INDEXATTR__; "Schema \"" . $desiredSchema . "\" not found, using current \"" . $this->coreSchema . "\"\n" ); } - /* Commit SET otherwise it will be rollbacked on error or IGNORE SELECT */ - $this->commit( __METHOD__, self::FLUSHING_INTERNAL ); } /** @@ -1243,10 +1245,14 @@ SQL; return false; // short-circuit } - $exists = $this->selectField( - '"pg_catalog"."pg_namespace"', 1, [ 'nspname' => $schema ], __METHOD__ ); + $res = $this->query( + "SELECT 1 FROM pg_catalog.pg_namespace " . + "WHERE nspname = " . $this->addQuotes( $schema ) . " LIMIT 1", + __METHOD__, + self::QUERY_IGNORE_DBO_TRX + ); - return (bool)$exists; + return ( $this->numRows( $res ) > 0 ); } /** @@ -1277,11 +1283,7 @@ SQL; * @return string */ public function fieldType( $res, $index ) { - if ( $res instanceof ResultWrapper ) { - $res = $res->result; - } - - return pg_field_type( $res, $index ); + return pg_field_type( ResultWrapper::unwrap( $res ), $index ); } public function encodeBlob( $b ) { diff --git a/includes/libs/rdbms/database/DatabaseSqlite.php b/includes/libs/rdbms/database/DatabaseSqlite.php index c9942a5906..c875e56684 100644 --- a/includes/libs/rdbms/database/DatabaseSqlite.php +++ b/includes/libs/rdbms/database/DatabaseSqlite.php @@ -23,6 +23,7 @@ */ namespace Wikimedia\Rdbms; +use NullLockManager; use PDO; use PDOException; use Exception; @@ -39,7 +40,7 @@ class DatabaseSqlite extends Database { /** @var bool Whether full text is enabled */ private static $fulltextEnabled = null; - /** @var string Directory */ + /** @var string|null Directory */ protected $dbDir; /** @var string File name for SQLite database file */ protected $dbPath; @@ -91,10 +92,16 @@ class DatabaseSqlite extends Database { $this->queryLogger->warning( "Invalid SQLite transaction mode provided." ); } - $this->lockMgr = new FSLockManager( [ - 'domain' => $lockDomain, - 'lockDirectory' => "{$this->dbDir}/locks" - ] ); + if ( $this->hasProcessMemoryPath() ) { + $this->lockMgr = new NullLockManager( [ 'domain' => $lockDomain ] ); + } else { + $this->lockMgr = new FSLockManager( [ + 'domain' => $lockDomain, + 'lockDirectory' => is_string( $this->dbDir ) + ? "{$this->dbDir}/locks" + : dirname( $this->dbPath ) . "/locks" + ] ); + } parent::__construct( $p ); } @@ -168,15 +175,13 @@ class DatabaseSqlite extends Database { protected function open( $server, $user, $pass, $dbName, $schema, $tablePrefix ) { $this->close(); - $fileName = self::generateFileName( $this->dbDir, $dbName ); - if ( !is_readable( $fileName ) ) { - $this->conn = false; - throw new DBConnectionError( $this, "SQLite database not accessible" ); + + if ( $schema !== null ) { + throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." ); } - // Only $dbName is used, the other parameters are irrelevant for SQLite databases - $this->openFile( $fileName, $dbName, $tablePrefix ); - return (bool)$this->conn; + // Only $dbName is used, the other parameters are irrelevant for SQLite databases + $this->openFile( self::generateFileName( $this->dbDir, $dbName ), $dbName, $tablePrefix ); } /** @@ -186,45 +191,57 @@ class DatabaseSqlite extends Database { * @param string $dbName * @param string $tablePrefix * @throws DBConnectionError - * @return PDO|bool SQL connection or false if failed */ protected function openFile( $fileName, $dbName, $tablePrefix ) { - $err = false; + if ( !$this->hasProcessMemoryPath() && !is_readable( $fileName ) ) { + $error = "SQLite database file not readable"; + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); + } $this->dbPath = $fileName; try { - if ( $this->flags & self::DBO_PERSISTENT ) { - $this->conn = new PDO( "sqlite:$fileName", '', '', - [ PDO::ATTR_PERSISTENT => true ] ); - } else { - $this->conn = new PDO( "sqlite:$fileName", '', '' ); - } + $this->conn = new PDO( + "sqlite:$fileName", + '', + '', + [ PDO::ATTR_PERSISTENT => (bool)( $this->flags & self::DBO_PERSISTENT ) ] + ); + $error = 'unknown error'; } catch ( PDOException $e ) { - $err = $e->getMessage(); + $error = $e->getMessage(); } if ( !$this->conn ) { - $this->queryLogger->debug( "DB connection error: $err\n" ); - throw new DBConnectionError( $this, $err ); + $this->connLogger->error( + "Error connecting to {db_server}: {error}", + $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] ) + ); + throw new DBConnectionError( $this, $error ); } - $this->opened = is_object( $this->conn ); - if ( $this->opened ) { - $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix ); - # Set error codes only, don't raise exceptions + try { + // Set error codes only, don't raise exceptions $this->conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT ); - # Enforce LIKE to be case sensitive, just like MySQL - $this->query( 'PRAGMA case_sensitive_like = 1' ); - $sync = $this->sessionVars['synchronous'] ?? null; - if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL' ], true ) ) { - $this->query( "PRAGMA synchronous = $sync" ); - } + $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix ); - return $this->conn; + $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY; + // Enforce LIKE to be case sensitive, just like MySQL + $this->query( 'PRAGMA case_sensitive_like = 1', __METHOD__, $flags ); + // Apply an optimizations or requirements regarding fsync() usage + $sync = $this->connectionVariables['synchronous'] ?? null; + if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL', 'OFF' ], true ) ) { + $this->query( "PRAGMA synchronous = $sync", __METHOD__ ); + } + } catch ( Exception $e ) { + // Connection was not fully initialized and is not safe for use + $this->conn = false; + throw $e; } - - return false; } /** @@ -303,7 +320,7 @@ class DatabaseSqlite extends Database { * @param bool|string $file Database file name. If omitted, will be generated * using $name and configured data directory * @param string $fname Calling function name - * @return ResultWrapper + * @return IResultWrapper */ function attachDatabase( $name, $file = false, $fname = __METHOD__ ) { if ( !$file ) { @@ -330,7 +347,7 @@ class DatabaseSqlite extends Database { * SQLite doesn't allow buffered results or data seeking etc, so we'll use fetchAll as the result * * @param string $sql - * @return bool|ResultWrapper + * @return bool|IResultWrapper */ protected function doQuery( $sql ) { $res = $this->getBindingHandle()->query( $sql ); @@ -338,38 +355,32 @@ class DatabaseSqlite extends Database { return false; } - $r = $res instanceof ResultWrapper ? $res->result : $res; - $this->lastAffectedRowCount = $r->rowCount(); - $res = new ResultWrapper( $this, $r->fetchAll() ); + $resource = ResultWrapper::unwrap( $res ); + $this->lastAffectedRowCount = $resource->rowCount(); + $res = new ResultWrapper( $this, $resource->fetchAll() ); return $res; } /** - * @param ResultWrapper|mixed $res + * @param IResultWrapper|mixed $res */ function freeResult( $res ) { if ( $res instanceof ResultWrapper ) { - $res->result = null; - } else { - $res = null; + $res->free(); } } /** - * @param ResultWrapper|array $res + * @param IResultWrapper|array $res * @return stdClass|bool */ function fetchObject( $res ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } + $resource =& ResultWrapper::unwrap( $res ); - $cur = current( $r ); + $cur = current( $resource ); if ( is_array( $cur ) ) { - next( $r ); + next( $resource ); $obj = new stdClass; foreach ( $cur as $k => $v ) { if ( !is_numeric( $k ) ) { @@ -384,18 +395,14 @@ class DatabaseSqlite extends Database { } /** - * @param ResultWrapper|mixed $res + * @param IResultWrapper|mixed $res * @return array|bool */ function fetchRow( $res ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } - $cur = current( $r ); + $resource =& ResultWrapper::unwrap( $res ); + $cur = current( $resource ); if ( is_array( $cur ) ) { - next( $r ); + next( $resource ); return $cur; } @@ -406,25 +413,25 @@ class DatabaseSqlite extends Database { /** * The PDO::Statement class implements the array interface so count() will work * - * @param ResultWrapper|array|false $res + * @param IResultWrapper|array|false $res * @return int */ function numRows( $res ) { // false does not implement Countable - $r = $res instanceof ResultWrapper ? $res->result : $res; + $resource = ResultWrapper::unwrap( $res ); - return is_array( $r ) ? count( $r ) : 0; + return is_array( $resource ) ? count( $resource ) : 0; } /** - * @param ResultWrapper $res + * @param IResultWrapper $res * @return int */ function numFields( $res ) { - $r = $res instanceof ResultWrapper ? $res->result : $res; - if ( is_array( $r ) && count( $r ) > 0 ) { + $resource = ResultWrapper::unwrap( $res ); + if ( is_array( $resource ) && count( $resource ) > 0 ) { // The size of the result array is twice the number of fields. (T67578) - return count( $r[0] ) / 2; + return count( $resource[0] ) / 2; } else { // If the result is empty return 0 return 0; @@ -432,14 +439,14 @@ class DatabaseSqlite extends Database { } /** - * @param ResultWrapper $res + * @param IResultWrapper $res * @param int $n * @return bool */ function fieldName( $res, $n ) { - $r = $res instanceof ResultWrapper ? $res->result : $res; - if ( is_array( $r ) ) { - $keys = array_keys( $r[0] ); + $resource = ResultWrapper::unwrap( $res ); + if ( is_array( $resource ) ) { + $keys = array_keys( $resource[0] ); return $keys[$n]; } @@ -474,19 +481,15 @@ class DatabaseSqlite extends Database { } /** - * @param ResultWrapper|array $res + * @param IResultWrapper|array $res * @param int $row */ function dataSeek( $res, $row ) { - if ( $res instanceof ResultWrapper ) { - $r =& $res->result; - } else { - $r =& $res; - } - reset( $r ); + $resource =& ResultWrapper::unwrap( $res ); + reset( $resource ); if ( $row > 0 ) { for ( $i = 0; $i < $row; $i++ ) { - next( $r ); + next( $resource ); } } } @@ -761,6 +764,17 @@ class DatabaseSqlite extends Database { return false; } + public function serverIsReadOnly() { + return ( !$this->hasProcessMemoryPath() && !is_writable( $this->dbPath ) ); + } + + /** + * @return bool + */ + private function hasProcessMemoryPath() { + return ( strpos( $this->dbPath, ':memory:' ) === 0 ); + } + /** * @return string Wikitext of a link to the server software's web site */ @@ -804,7 +818,6 @@ class DatabaseSqlite extends Database { } else { $this->query( 'BEGIN', $fname ); } - $this->trxLevel = 1; } /** @@ -954,17 +967,19 @@ class DatabaseSqlite extends Database { } public function lock( $lockName, $method, $timeout = 5 ) { - if ( !is_dir( "{$this->dbDir}/locks" ) ) { // create dir as needed - if ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) ) { - throw new DBError( $this, "Cannot create directory \"{$this->dbDir}/locks\"." ); - } + // Give better error message for permission problems than just returning false + if ( + !is_dir( "{$this->dbDir}/locks" ) && + ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) ) + ) { + throw new DBError( $this, "Cannot create directory \"{$this->dbDir}/locks\"." ); } return $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout )->isOK(); } public function unlock( $lockName, $method ) { - return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isOK(); + return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isGood(); } /** @@ -990,7 +1005,7 @@ class DatabaseSqlite extends Database { * @param string $newName * @param bool $temporary * @param string $fname - * @return bool|ResultWrapper + * @return bool|IResultWrapper * @throws RuntimeException */ function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) { @@ -1086,7 +1101,7 @@ class DatabaseSqlite extends Database { * * @param string $tableName * @param string $fName - * @return bool|ResultWrapper + * @return bool|IResultWrapper * @throws DBReadOnlyError */ public function dropTable( $tableName, $fName = __METHOD__ ) { @@ -1119,15 +1134,6 @@ class DatabaseSqlite extends Database { return true; } - /** - * @return string - */ - public function __toString() { - return is_object( $this->conn ) - ? 'SQLite ' . (string)$this->conn->getAttribute( PDO::ATTR_SERVER_VERSION ) - : '(not connected)'; - } - /** * @return PDO */ diff --git a/includes/libs/rdbms/database/IDatabase.php b/includes/libs/rdbms/database/IDatabase.php index 05f787cfa9..ad6d4d2f45 100644 --- a/includes/libs/rdbms/database/IDatabase.php +++ b/includes/libs/rdbms/database/IDatabase.php @@ -42,6 +42,8 @@ interface IDatabase { const TRIGGER_COMMIT = 2; /** @var int Callback triggered by ROLLBACK */ const TRIGGER_ROLLBACK = 3; + /** @var int Callback triggered by atomic section cancel (ROLLBACK TO SAVEPOINT) */ + const TRIGGER_CANCEL = 4; /** @var string Transaction is requested by regular caller outside of the DB layer */ const TRANSACTION_EXPLICIT = ''; @@ -106,6 +108,8 @@ interface IDatabase { /** @var int Enable compression in connection protocol */ const DBO_COMPRESS = 512; + /** @var int Idiom for "no special flags" */ + const QUERY_NORMAL = 0; /** @var int Ignore query errors and return false when they happen */ const QUERY_SILENCE_ERRORS = 1; // b/c for 1.32 query() argument; note that (int)true = 1 /** @@ -115,6 +119,10 @@ interface IDatabase { const QUERY_PSEUDO_PERMANENT = 2; /** @var int Enforce that a query does not make effective writes */ const QUERY_REPLICA_ROLE = 4; + /** @var int Ignore the current presence of any DBO_TRX flag */ + const QUERY_IGNORE_DBO_TRX = 8; + /** @var int Do not try to retry the query if the connection was lost */ + const QUERY_NO_RETRY = 16; /** @var bool Parameter to unionQueries() for UNION ALL */ const UNION_ALL = true; @@ -165,8 +173,10 @@ interface IDatabase { /** * Get the UNIX timestamp of the time that the transaction was established * - * This can be used to reason about the staleness of SELECT data - * in REPEATABLE-READ transaction isolation level. + * This can be used to reason about the staleness of SELECT data in REPEATABLE-READ + * transaction isolation level. Callers can assume that if a view-snapshot isolation + * is used, then the data read by SQL queries is *at least* up to date to that point + * (possibly more up-to-date since the first SELECT defines the snapshot). * * @return float|null Returns null if there is not active transaction * @since 1.25 @@ -217,7 +227,7 @@ interface IDatabase { * the LB info array is set to that parameter. If it is called with two * parameters, the member with the given name is set to the given value. * - * @param string $name + * @param array|string $name * @param array|null $value */ public function setLBInfo( $name, $value = null ); @@ -247,8 +257,8 @@ interface IDatabase { public function implicitOrderby(); /** - * Return the last query that went through IDatabase::query() - * @return string + * Return the last query that sent on account of IDatabase::query() + * @return string SQL text or empty string if there was no such query */ public function lastQuery(); @@ -713,7 +723,8 @@ interface IDatabase { * is applied to a result set after OFFSET. * * - FOR UPDATE: Boolean: lock the returned rows so that they can't be - * changed until the next COMMIT. + * changed until the next COMMIT. Cannot be used with aggregate functions + * (COUNT, MAX, etc., but also DISTINCT). * * - DISTINCT: Boolean: return only unique result rows. * @@ -800,7 +811,7 @@ interface IDatabase { * * @param string|array $table Table name * @param string|array $vars Field names - * @param array $conds Conditions + * @param string|array $conds Conditions * @param string $fname Caller function name * @param string|array $options Query options * @param array|string $join_conds Join conditions @@ -1215,9 +1226,12 @@ interface IDatabase { * $query .= $dbr->buildLike( $pattern ); * * @since 1.16 + * @param array[]|string|LikeMatch $param * @return string Fully built LIKE statement + * @phan-suppress-next-line PhanMismatchVariadicComment + * @phan-param array|string|LikeMatch ...$param T226223 */ - public function buildLike(); + public function buildLike( $param ); /** * Returns a token for buildLike() that denotes a '_' to be used in a LIKE query @@ -1568,6 +1582,9 @@ interface IDatabase { * * This is useful for combining cooperative locks and DB transactions. * + * Note this is called when the whole transaction is resolved. To take action immediately + * when an atomic section is cancelled, use onAtomicSectionCancel(). + * * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode * * The callback takes the following arguments: @@ -1649,6 +1666,31 @@ interface IDatabase { */ public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ); + /** + * Run a callback when the atomic section is cancelled. + * + * The callback is run just after the current atomic section, any outer + * atomic section, or the whole transaction is rolled back. + * + * An error is thrown if no atomic section is pending. The atomic section + * need not have been created with the ATOMIC_CANCELABLE flag. + * + * Queries in the function may be running in the context of an outer + * transaction or may be running in AUTOCOMMIT mode. The callback should + * use atomic sections if necessary. + * + * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * + * The callback takes the following arguments: + * - IDatabase::TRIGGER_CANCEL or IDatabase::TRIGGER_ROLLBACK + * - This IDatabase instance + * + * @param callable $callback + * @param string $fname Caller name + * @since 1.34 + */ + public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ); + /** * Run a callback after each time any transaction commits or rolls back * @@ -2070,7 +2112,7 @@ interface IDatabase { * * @param string $lockName Name of lock to aquire * @param string $method Name of the calling method - * @param int $timeout Acquisition timeout in seconds + * @param int $timeout Acquisition timeout in seconds (0 means non-blocking) * @return bool * @throws DBError */ @@ -2194,6 +2236,15 @@ interface IDatabase { * @since 1.31 */ public function setIndexAliases( array $aliases ); + + /** + * Get a debugging string that mentions the database type, the ID of this instance, + * and the ID of any underlying connection resource or driver object if one is present + * + * @return string " object #" or " object # (resource/handle id #)" + * @since 1.34 + */ + public function __toString(); } /** diff --git a/includes/libs/rdbms/database/IMaintainableDatabase.php b/includes/libs/rdbms/database/IMaintainableDatabase.php index 5706435dc5..28e94a0680 100644 --- a/includes/libs/rdbms/database/IMaintainableDatabase.php +++ b/includes/libs/rdbms/database/IMaintainableDatabase.php @@ -150,7 +150,7 @@ interface IMaintainableDatabase extends IDatabase { * Delete a table * @param string $tableName * @param string $fName - * @return bool|ResultWrapper + * @return bool|IResultWrapper */ public function dropTable( $tableName, $fName = __METHOD__ ); @@ -303,7 +303,7 @@ interface IMaintainableDatabase extends IDatabase { * @param string $table Table name * @param string $field Field name * - * @return Field + * @return false|Field */ public function fieldInfo( $table, $field ); } diff --git a/includes/libs/rdbms/database/domain/DatabaseDomain.php b/includes/libs/rdbms/database/domain/DatabaseDomain.php index 5dd4b49fba..5698cf824d 100644 --- a/includes/libs/rdbms/database/domain/DatabaseDomain.php +++ b/includes/libs/rdbms/database/domain/DatabaseDomain.php @@ -53,9 +53,7 @@ class DatabaseDomain { } $this->schema = $schema; if ( !is_string( $prefix ) ) { - throw new InvalidArgumentException( 'Prefix must be a string.' ); - } elseif ( $prefix !== '' && substr( $prefix, -1, 1 ) !== '_' ) { - throw new InvalidArgumentException( 'A non-empty prefix must end with "_".' ); + throw new InvalidArgumentException( "Prefix must be a string." ); } $this->prefix = $prefix; } @@ -92,7 +90,10 @@ class DatabaseDomain { $schema = null; } - return new self( $database, $schema, $prefix ); + $instance = new self( $database, $schema, $prefix ); + $instance->equivalentString = (string)$domain; + + return $instance; } /** diff --git a/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php b/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php index aeb5d8de60..1094fafc96 100644 --- a/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php +++ b/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php @@ -8,57 +8,74 @@ use stdClass; * Overloads the relevant methods of the real ResultsWrapper so it * doesn't go anywhere near an actual database. */ -class FakeResultWrapper extends ResultWrapper { - /** @var stdClass[] $result */ +class FakeResultWrapper implements IResultWrapper { + /** @var stdClass[]|array[] */ + protected $result; + + /** @var int */ + protected $pos = 0; /** - * @param stdClass[] $rows + * @param stdClass[]|array[]|FakeResultWrapper $result */ - function __construct( array $rows ) { - parent::__construct( null, $rows ); + public function __construct( $result ) { + if ( $result instanceof self ) { + $this->result = $result->result; + } else { + $this->result = $result; + } } - function numRows() { + public function numRows() { return count( $this->result ); } - function fetchRow() { - if ( $this->pos < count( $this->result ) ) { - $this->currentRow = $this->result[$this->pos]; - } else { - $this->currentRow = false; - } - $this->pos++; - if ( is_object( $this->currentRow ) ) { - return get_object_vars( $this->currentRow ); - } else { - return $this->currentRow; - } + public function fetchObject() { + $current = $this->current(); + + $this->next(); + + return $current; } - function seek( $row ) { - $this->pos = $row; + public function fetchRow() { + $row = $this->valid() ? $this->result[$this->pos] : false; + + $this->next(); + + return is_object( $row ) ? get_object_vars( $row ) : $row; } - function free() { + public function seek( $pos ) { + $this->pos = $pos; } - function fetchObject() { - $this->fetchRow(); - if ( $this->currentRow ) { - return (object)$this->currentRow; - } else { - return false; - } + public function free() { + $this->result = null; } - function rewind() { + public function rewind() { $this->pos = 0; - $this->currentRow = null; } - function next() { - return $this->fetchObject(); + public function current() { + $row = $this->valid() ? $this->result[$this->pos] : false; + + return is_array( $row ) ? (object)$row : $row; + } + + public function key() { + return $this->pos; + } + + public function next() { + $this->pos++; + + return $this->current(); + } + + public function valid() { + return array_key_exists( $this->pos, $this->result ); } } diff --git a/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php b/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php index debf8a27c3..616fed9d13 100644 --- a/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php +++ b/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php @@ -52,9 +52,9 @@ interface IResultWrapper extends Iterator { * Change the position of the cursor in a result object. * See mysql_data_seek() * - * @param int $row + * @param int $pos */ - public function seek( $row ); + public function seek( $pos ); /** * Free a result object diff --git a/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php b/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php index a9befc2bae..3e509677a3 100644 --- a/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php +++ b/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php @@ -4,50 +4,67 @@ namespace Wikimedia\Rdbms; use stdClass; use RuntimeException; +use InvalidArgumentException; /** * Result wrapper for grabbing data queried from an IDatabase object * + * Only IDatabase-related classes should construct these. Other code may + * use the FakeResultWrapper class for convenience or compatibility shims. + * * Note that using the Iterator methods in combination with the non-Iterator - * DB result iteration functions may cause rows to be skipped or repeated. + * IDatabase result iteration functions may cause rows to be skipped or repeated. * * By default, this will use the iteration methods of the IDatabase handle if provided. * Subclasses can override methods to make it solely work on the result resource instead. - * If no database is provided, and the subclass does not override the DB iteration methods, - * then a RuntimeException will be thrown when iteration is attempted. - * - * The result resource field should not be accessed from non-Database related classes. - * It is database class specific and is stored here to associate iterators with queries. * * @ingroup Database */ class ResultWrapper implements IResultWrapper { - /** @var resource|array|null Optional underlying result handle for subclass usage */ - public $result; - - /** @var IDatabase|null */ + /** @var IDatabase */ protected $db; + /** @var mixed|null RDBMS driver-specific result resource */ + protected $result; /** @var int */ protected $pos = 0; - /** @var stdClass|null */ - protected $currentRow = null; + /** @var stdClass|bool|null */ + protected $currentRow; /** - * Create a row iterator from a result resource and an optional Database object - * - * Only Database-related classes should construct ResultWrapper. Other code may - * use the FakeResultWrapper subclass for convenience or compatibility shims, however. - * - * @param IDatabase|null $db Optional database handle - * @param ResultWrapper|array|resource $result Optional underlying result handle + * @param IDatabase $db Database handle that the result comes from + * @param self|mixed $result RDBMS driver-specific result resource */ - public function __construct( IDatabase $db = null, $result ) { + public function __construct( IDatabase $db, $result ) { $this->db = $db; - if ( $result instanceof ResultWrapper ) { + if ( $result instanceof self ) { $this->result = $result->result; - } else { + } elseif ( $result !== null ) { $this->result = $result; + } else { + throw new InvalidArgumentException( "Null result resource provided" ); + } + } + + /** + * Get the underlying RDBMS driver-specific result resource + * + * The result resource field should not be accessed from non-Database related classes. + * It is database class specific and is stored here to associate iterators with queries. + * + * @param self|mixed &$res + * @return mixed + * @since 1.34 + */ + public static function &unwrap( &$res ) { + if ( $res instanceof self ) { + if ( $res->result === null ) { + throw new RuntimeException( "The result resource was already freed" ); + } + + return $res->result; + } else { + return $res; } } @@ -63,29 +80,16 @@ class ResultWrapper implements IResultWrapper { return $this->getDB()->fetchRow( $this ); } - public function seek( $row ) { - $this->getDB()->dataSeek( $this, $row ); + public function seek( $pos ) { + $this->getDB()->dataSeek( $this, $pos ); + $this->pos = $pos; } public function free() { - if ( $this->db ) { - $this->db = null; - } + $this->db = null; $this->result = null; } - /** - * @return IDatabase - * @throws RuntimeException - */ - private function getDB() { - if ( !$this->db ) { - throw new RuntimeException( static::class . ' needs a DB handle for iteration.' ); - } - - return $this->db; - } - function rewind() { if ( $this->numRows() ) { $this->getDB()->dataSeek( $this, 0 ); @@ -95,8 +99,8 @@ class ResultWrapper implements IResultWrapper { } function current() { - if ( is_null( $this->currentRow ) ) { - $this->next(); + if ( $this->currentRow === null ) { + $this->currentRow = $this->fetchObject(); } return $this->currentRow; @@ -116,6 +120,18 @@ class ResultWrapper implements IResultWrapper { function valid() { return $this->current() !== false; } + + /** + * @return IDatabase + * @throws RuntimeException + */ + private function getDB() { + if ( !$this->db ) { + throw new RuntimeException( "Database handle was already freed" ); + } + + return $this->db; + } } /** diff --git a/includes/libs/rdbms/database/utils/GeneralizedSql.php b/includes/libs/rdbms/database/utils/GeneralizedSql.php new file mode 100644 index 0000000000..db5c639c56 --- /dev/null +++ b/includes/libs/rdbms/database/utils/GeneralizedSql.php @@ -0,0 +1,95 @@ +rawSql = $rawSql; + $this->trxId = $trxId; + $this->prefix = $prefix; + } + + /** + * Removes most variables from an SQL query and replaces them with X or N for numbers. + * It's only slightly flawed. Don't use for anything important. + * + * @param string $sql A SQL Query + * + * @return string + */ + private static function generalizeSQL( $sql ) { + # This does the same as the regexp below would do, but in such a way + # as to avoid crashing php on some large strings. + # $sql = preg_replace( "/'([^\\\\']|\\\\.)*'|\"([^\\\\\"]|\\\\.)*\"/", "'X'", $sql ); + + $sql = str_replace( "\\\\", '', $sql ); + $sql = str_replace( "\\'", '', $sql ); + $sql = str_replace( "\\\"", '', $sql ); + $sql = preg_replace( "/'.*'/s", "'X'", $sql ); + $sql = preg_replace( '/".*"/s', "'X'", $sql ); + + # All newlines, tabs, etc replaced by single space + $sql = preg_replace( '/\s+/', ' ', $sql ); + + # All numbers => N, + # except the ones surrounded by characters, e.g. l10n + $sql = preg_replace( '/-?\d+(,-?\d+)+/s', 'N,...,N', $sql ); + $sql = preg_replace( '/(?genericSql !== null ) { + return $this->genericSql; + } + + $this->genericSql = $this->prefix . + substr( self::generalizeSQL( $this->rawSql ), 0, 255 ) . + ( $this->trxId ? " [TRX#{$this->trxId}]" : "" ); + + return $this->genericSql; + } +} diff --git a/includes/libs/rdbms/exception/DBQueryDisconnectedError.php b/includes/libs/rdbms/exception/DBQueryDisconnectedError.php new file mode 100644 index 0000000000..a142b4a21b --- /dev/null +++ b/includes/libs/rdbms/exception/DBQueryDisconnectedError.php @@ -0,0 +1,30 @@ + LoadBalancer) + * @return ILoadBalancer[] Map of (cluster name => ILoadBalancer) * @since 1.29 */ public function getAllMainLBs(); @@ -148,7 +148,7 @@ interface ILBFactory { /** * Get cached (tracked) load balancers for all external database clusters * - * @return LoadBalancer[] Map of (cluster name => LoadBalancer) + * @return ILoadBalancer[] Map of (cluster name => ILoadBalancer) * @since 1.29 */ public function getAllExternalLBs(); diff --git a/includes/libs/rdbms/lbfactory/LBFactory.php b/includes/libs/rdbms/lbfactory/LBFactory.php index bf10053385..e20f6de638 100644 --- a/includes/libs/rdbms/lbfactory/LBFactory.php +++ b/includes/libs/rdbms/lbfactory/LBFactory.php @@ -85,7 +85,9 @@ abstract class LBFactory implements ILBFactory { /** @var callable[] */ private $replicationWaitCallbacks = []; - /** @var mixed */ + /** var int An identifier for this class instance */ + private $id; + /** @var int|null Ticket used to delegate transaction ownership */ private $ticket; /** @var string|bool String if a requested DBO_TRX transaction round is active */ private $trxRoundId = false; @@ -153,6 +155,7 @@ abstract class LBFactory implements ILBFactory { $this->defaultGroup = $conf['defaultGroup'] ?? null; $this->secret = $conf['secret'] ?? ''; + $this->id = mt_rand(); $this->ticket = mt_rand(); } @@ -251,7 +254,7 @@ abstract class LBFactory implements ILBFactory { } $this->trxRoundId = $fname; // Set DBO_TRX flags on all appropriate DBs - $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname, $this->id ] ); $this->trxRoundStage = self::ROUND_CURSORY; } @@ -269,17 +272,17 @@ abstract class LBFactory implements ILBFactory { // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure do { $count = 0; // number of callbacks executed this iteration - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count ) { - $count += $lb->finalizeMasterChanges(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count, $fname ) { + $count += $lb->finalizeMasterChanges( $fname, $this->id ); } ); } while ( $count > 0 ); $this->trxRoundId = false; // Perform pre-commit checks, aborting on failure - $this->forEachLBCallMethod( 'approveMasterChanges', [ $options ] ); + $this->forEachLBCallMethod( 'approveMasterChanges', [ $options, $fname, $this->id ] ); // Log the DBs and methods involved in multi-DB transactions $this->logIfMultiDbTransaction(); // Actually perform the commit on all master DB connections and revert DBO_TRX - $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname, $this->id ] ); // Run all post-commit callbacks in a separate step $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; $e = $this->executePostTransactionCallbacks(); @@ -294,7 +297,7 @@ abstract class LBFactory implements ILBFactory { $this->trxRoundStage = self::ROUND_ROLLING_BACK; $this->trxRoundId = false; // Actually perform the rollback on all master DB connections and revert DBO_TRX - $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] ); + $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname, $this->id ] ); // Run all post-commit callbacks in a separate step $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS; $this->executePostTransactionCallbacks(); @@ -305,17 +308,18 @@ abstract class LBFactory implements ILBFactory { * @return Exception|null */ private function executePostTransactionCallbacks() { + $fname = __METHOD__; // Run all post-commit callbacks until new ones stop getting added $e = null; // first callback exception do { - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { - $ex = $lb->runMasterTransactionIdleCallbacks(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) { + $ex = $lb->runMasterTransactionIdleCallbacks( $fname, $this->id ); $e = $e ?: $ex; } ); } while ( $this->hasMasterChanges() ); // Run all listener callbacks once - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { - $ex = $lb->runMasterTransactionListenerCallbacks(); + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) { + $ex = $lb->runMasterTransactionListenerCallbacks( $fname, $this->id ); $e = $e ?: $ex; } ); @@ -412,12 +416,11 @@ abstract class LBFactory implements ILBFactory { // time needed to wait on the next clusters. $masterPositions = array_fill( 0, count( $lbs ), false ); foreach ( $lbs as $i => $lb ) { - if ( $lb->getServerCount() <= 1 ) { - // T29975 - Don't try to wait for replica DBs if there are none - // Prevents permission error when getting master position - continue; - } elseif ( $opts['ifWritesSince'] - && $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince'] + if ( !$lb->hasStreamingReplicaServers() ) { + continue; // T29975: no replication; avoid getMasterPos() permissions errors + } elseif ( + $opts['ifWritesSince'] && + $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince'] ) { continue; // no writes since the last wait } @@ -511,7 +514,7 @@ abstract class LBFactory implements ILBFactory { [ 'ip' => $this->requestInfo['IPAddress'], 'agent' => $this->requestInfo['UserAgent'], - 'clientId' => $this->requestInfo['ChronologyClientId'] + 'clientId' => $this->requestInfo['ChronologyClientId'] ?: null ], $this->requestInfo['ChronologyPositionIndex'], $this->secret @@ -549,7 +552,7 @@ abstract class LBFactory implements ILBFactory { ) { // Record all the master positions needed $this->forEachLB( function ( ILoadBalancer $lb ) use ( $cp ) { - $cp->shutdownLB( $lb ); + $cp->storeSessionReplicationPosition( $lb ); } ); // Write them to the persistent stash. Try to do something useful by running $work // while ChronologyProtector waits for the stash write to replicate to all DCs. @@ -603,9 +606,10 @@ abstract class LBFactory implements ILBFactory { 'chronologyCallback' => function ( ILoadBalancer $lb ) { // Defer ChronologyProtector construction in case setRequestInfo() ends up // being called later (but before the first connection attempt) (T192611) - $this->getChronologyProtector()->initLB( $lb ); + $this->getChronologyProtector()->applySessionReplicationPosition( $lb ); }, - 'roundStage' => $initStage + 'roundStage' => $initStage, + 'ownerId' => $this->id ]; } @@ -614,7 +618,7 @@ abstract class LBFactory implements ILBFactory { */ protected function initLoadBalancer( ILoadBalancer $lb ) { if ( $this->trxRoundId !== false ) { - $lb->beginMasterChanges( $this->trxRoundId ); // set DBO_TRX + $lb->beginMasterChanges( $this->trxRoundId, $this->id ); // set DBO_TRX } $lb->setTableAliases( $this->tableAliases ); @@ -670,7 +674,7 @@ abstract class LBFactory implements ILBFactory { public function appendShutdownCPIndexAsQuery( $url, $index ) { $usedCluster = 0; $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$usedCluster ) { - $usedCluster |= ( $lb->getServerCount() > 1 ); + $usedCluster |= $lb->hasStreamingReplicaServers(); } ); if ( !$usedCluster ) { diff --git a/includes/libs/rdbms/lbfactory/LBFactoryMulti.php b/includes/libs/rdbms/lbfactory/LBFactoryMulti.php index aec99f4ec7..f675b58778 100644 --- a/includes/libs/rdbms/lbfactory/LBFactoryMulti.php +++ b/includes/libs/rdbms/lbfactory/LBFactoryMulti.php @@ -34,55 +34,42 @@ use InvalidArgumentException; class LBFactoryMulti extends LBFactory { /** @var array A map of database names to section names */ private $sectionsByDB; - /** * @var array A 2-d map. For each section, gives a map of server names to * load ratios */ private $sectionLoads; - /** * @var array[] Server info associative array * @note The host, hostName and load entries will be overridden */ private $serverTemplate; - // Optional settings - /** @var array A 3-d map giving server load ratios for each section and group */ private $groupLoadsBySection = []; - /** @var array A 3-d map giving server load ratios by DB name */ private $groupLoadsByDB = []; - /** @var array A map of hostname to IP address */ private $hostsByName = []; - /** @var array A map of external storage cluster name to server load map */ private $externalLoads = []; - /** * @var array A set of server info keys overriding serverTemplate for * external storage */ private $externalTemplateOverrides; - /** * @var array A 2-d map overriding serverTemplate and * externalTemplateOverrides on a server-by-server basis. Applies to both * core and external storage */ private $templateOverridesByServer; - /** @var array A 2-d map overriding the server info by section */ private $templateOverridesBySection; - /** @var array A 2-d map overriding the server info by external storage cluster */ private $templateOverridesByCluster; - /** @var array An override array for all master servers */ private $masterTemplateOverrides; - /** * @var array|bool A map of section name to read-only message. Missing or * false for read/write @@ -91,16 +78,12 @@ class LBFactoryMulti extends LBFactory { /** @var LoadBalancer[] */ private $mainLBs = []; - /** @var LoadBalancer[] */ private $extLBs = []; - /** @var string */ private $loadMonitorClass = 'LoadMonitor'; - /** @var string */ private $lastDomain; - /** @var string */ private $lastSection; @@ -191,22 +174,19 @@ class LBFactoryMulti extends LBFactory { if ( $this->lastDomain === $domain ) { return $this->lastSection; } - list( $dbName, ) = $this->getDBNameAndPrefix( $domain ); - $section = $this->sectionsByDB[$dbName] ?? 'DEFAULT'; + + $database = $this->getDatabaseFromDomain( $domain ); + $section = $this->sectionsByDB[$database] ?? 'DEFAULT'; $this->lastSection = $section; $this->lastDomain = $domain; return $section; } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function newMainLB( $domain = false ) { - list( $dbName, ) = $this->getDBNameAndPrefix( $domain ); + $database = $this->getDatabaseFromDomain( $domain ); $section = $this->getSectionForDomain( $domain ); - $groupLoads = $this->groupLoadsByDB[$dbName] ?? []; + $groupLoads = $this->groupLoadsByDB[$database] ?? []; if ( isset( $this->groupLoadsBySection[$section] ) ) { $groupLoads = array_merge_recursive( @@ -232,10 +212,6 @@ class LBFactoryMulti extends LBFactory { ); } - /** - * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain - * @return LoadBalancer - */ public function getMainLB( $domain = false ) { $section = $this->getSectionForDomain( $domain ); if ( !isset( $this->mainLBs[$section] ) ) { @@ -379,23 +355,14 @@ class LBFactoryMulti extends LBFactory { /** * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain - * @return array [database name, table prefix] + * @return string */ - private function getDBNameAndPrefix( $domain = false ) { - $domain = ( $domain === false ) - ? $this->localDomain - : DatabaseDomain::newFromId( $domain ); - - return [ $domain->getDatabase(), $domain->getTablePrefix() ]; + private function getDatabaseFromDomain( $domain = false ) { + return ( $domain === false ) + ? $this->localDomain->getDatabase() + : DatabaseDomain::newFromId( $domain )->getDatabase(); } - /** - * Execute a function for each tracked load balancer - * The callback is called with the load balancer as the first parameter, - * and $params passed as the subsequent parameters. - * @param callable $callback - * @param array $params - */ public function forEachLB( $callback, array $params = [] ) { foreach ( $this->mainLBs as $lb ) { $callback( $lb, ...$params ); diff --git a/includes/libs/rdbms/lbfactory/LBFactorySimple.php b/includes/libs/rdbms/lbfactory/LBFactorySimple.php index 49054e083d..fd76d88dd0 100644 --- a/includes/libs/rdbms/lbfactory/LBFactorySimple.php +++ b/includes/libs/rdbms/lbfactory/LBFactorySimple.php @@ -70,20 +70,12 @@ class LBFactorySimple extends LBFactory { $this->loadMonitorClass = $conf['loadMonitorClass'] ?? 'LoadMonitor'; } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function newMainLB( $domain = false ) { return $this->newLoadBalancer( $this->servers ); } - /** - * @param bool|string $domain - * @return LoadBalancer - */ public function getMainLB( $domain = false ) { - if ( !isset( $this->mainLB ) ) { + if ( !$this->mainLB ) { $this->mainLB = $this->newMainLB( $domain ); } @@ -132,14 +124,6 @@ class LBFactorySimple extends LBFactory { return $lb; } - /** - * Execute a function for each tracked load balancer - * The callback is called with the load balancer as the first parameter, - * and $params passed as the subsequent parameters. - * - * @param callable $callback - * @param array $params - */ public function forEachLB( $callback, array $params = [] ) { if ( isset( $this->mainLB ) ) { $callback( $this->mainLB, ...$params ); diff --git a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php index 52d8370d8f..2f9857fe9d 100644 --- a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php @@ -23,6 +23,7 @@ namespace Wikimedia\Rdbms; use Exception; +use LogicException; use InvalidArgumentException; /** @@ -62,6 +63,8 @@ use InvalidArgumentException; * Note that lag is still possible depending on how wsrep-sync-wait is set server-side. * - Read-only archive clones: set 'is static' in the server configuration maps. This will * treat all such DBs as having 0 lag. + * - Externally updated dataset clones: set 'is static' in the server configuration maps. + * This will treat all such DBs as having 0 lag. * - SQL load balancing proxy: any proxy should handle lag checks on its own, so the 'max lag' * parameter should probably be set to INF in the server configuration maps. This will make * the load balancer ignore whatever it detects as the lag of the logical replica is (which @@ -83,9 +86,13 @@ interface ILoadBalancer { /** @var string Domain specifier when no specific database needs to be selected */ const DOMAIN_ANY = ''; + /** @var bool The generic query group (bool gives b/c with 1.33 method signatures) */ + const GROUP_GENERIC = false; /** @var int DB handle should have DBO_TRX disabled and the caller will leave it as such */ const CONN_TRX_AUTOCOMMIT = 1; + /** @var int Return null on connection failure instead of throwing an exception */ + const CONN_SILENCE_ERRORS = 2; /** @var string Manager of ILoadBalancer instances is running post-commit callbacks */ const STAGE_POSTCOMMIT_CALLBACKS = 'stage-postcommit-callbacks'; @@ -96,26 +103,28 @@ interface ILoadBalancer { * Construct a manager of IDatabase connection objects * * @param array $params Parameter map with keys: - * - servers : Required. Array of server info structures. - * - localDomain: A DatabaseDomain or domain ID string. - * - loadMonitor : Name of a class used to fetch server lag and load. + * - servers : List of server info structures + * - localDomain: A DatabaseDomain or domain ID string + * - loadMonitor : Name of a class used to fetch server lag and load * - readOnlyReason : Reason the master DB is read-only if so [optional] * - waitTimeout : Maximum time to wait for replicas for consistency [optional] * - maxLag: Try to avoid DB replicas with lag above this many seconds [optional] * - srvCache : BagOStuff object for server cache [optional] * - wanCache : WANObjectCache object [optional] * - chronologyCallback: Callback to run before the first connection attempt [optional] + * - defaultGroup: Default query group; the generic group if not specified [optional] * - hostname : The name of the current server [optional] - * - cliMode: Whether the execution context is a CLI script. [optional] - * - profiler : Class name or instance with profileIn()/profileOut() methods. [optional] - * - trxProfiler: TransactionProfiler instance. [optional] - * - replLogger: PSR-3 logger instance. [optional] - * - connLogger: PSR-3 logger instance. [optional] - * - queryLogger: PSR-3 logger instance. [optional] - * - perfLogger: PSR-3 logger instance. [optional] - * - errorLogger : Callback that takes an Exception and logs it. [optional] - * - deprecationLogger: Callback to log a deprecation warning. [optional] + * - cliMode: Whether the execution context is a CLI script [optional] + * - profiler : Class name or instance with profileIn()/profileOut() methods [optional] + * - trxProfiler: TransactionProfiler instance [optional] + * - replLogger: PSR-3 logger instance [optional] + * - connLogger: PSR-3 logger instance [optional] + * - queryLogger: PSR-3 logger instance [optional] + * - perfLogger: PSR-3 logger instance [optional] + * - errorLogger : Callback that takes an Exception and logs it [optional] + * - deprecationLogger: Callback to log a deprecation warning [optional] * - roundStage: STAGE_POSTCOMMIT_* class constant; for internal use [optional] + * - ownerId: integer ID of an LBFactory instance that manages this instance [optional] * @throws InvalidArgumentException */ public function __construct( array $params ); @@ -131,7 +140,7 @@ interface ILoadBalancer { /** * @param DatabaseDomain|string|bool $domain Database domain - * @return string Value of $domain if provided or the local domain otherwise + * @return string Value of $domain if it is foreign or the local domain otherwise * @since 1.32 */ public function resolveDomainID( $domain ); @@ -145,26 +154,29 @@ interface ILoadBalancer { public function redefineLocalDomain( $domain ); /** - * Get the index of the reader connection, which may be a replica DB + * Get the server index of the reader connection for a given group * - * This takes into account load ratios and lag times. It should - * always return a consistent index during a given invocation. + * This takes into account load ratios and lag times. It should return a consistent + * index during the life time of the load balancer. This initially checks replica DBs + * for connectivity to avoid returning an unusable server. This means that connections + * might be attempted by calling this method (usally one at the most but possibly more). + * Subsequent calls with the same $group will not need to make new connection attempts + * since the acquired connection for each group is preserved. * - * Side effect: opens connections to databases - * @param string|bool $group Query group, or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain - * @throws DBError + * @param string|bool $group Query group or false for the generic group + * @param string|bool $domain DB domain ID or false for the local domain + * @throws DBError If no live handle can be obtained * @return bool|int|string */ public function getReaderIndex( $group = false, $domain = false ); /** - * Set the master wait position + * Set the master position to reach before the next generic group DB handle query * - * If a DB_REPLICA connection has been opened already, then wait immediately. - * Otherwise sets a variable telling it to wait if such a connection is opened. + * If a generic replica DB connection is already open then this immediately waits + * for that DB to catch up to the specified replication position. Otherwise, it will + * do so once such a connection is opened. * - * This only applies to connections to the generic replica DB for this request. * If a timeout happens when waiting, then getLaggedReplicaMode()/laggedReplicaUsed() * will return true. * @@ -173,7 +185,7 @@ interface ILoadBalancer { public function waitFor( $pos ); /** - * Set the master wait position and wait for a "generic" replica DB to catch up to it + * Set the master wait position and wait for a generic replica DB to catch up to it * * This can be used a faster proxy for waitForAll() * @@ -196,7 +208,8 @@ interface ILoadBalancer { * Get any open connection to a given server index, local or foreign * * Use CONN_TRX_AUTOCOMMIT to only look for connections opened with that flag. - * Avoid the use of begin() or startAtomic() on any such connections. + * Avoid the use of transaction methods like IDatabase::begin() or IDatabase::startAtomic() + * on any such connections. * * @param int $i Server index or DB_MASTER/DB_REPLICA * @param int $flags Bitfield of CONN_* class constants @@ -205,152 +218,186 @@ interface ILoadBalancer { public function getAnyOpenConnection( $i, $flags = 0 ); /** - * Get a connection handle by server index - * - * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() - * can be used to check such flags beforehand. - * - * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must - * also call ILoadBalancer::reuseConnection() on the handle when finished using it. - * In all other cases, this is not necessary, though not harmful either. - * Avoid the use of begin() or startAtomic() on any such connections. + * Get a live handle for a real or virtual (DB_MASTER/DB_REPLICA) server index + * + * The server index, $i, can be one of the following: + * - DB_REPLICA: a server index will be selected by the load balancer based on read + * weight, connectivity, and replication lag. Note that the master server might be + * configured with read weight. If $groups is empty then it means "the generic group", + * in which case all servers defined with read weight will be considered. Additional + * query groups can be configured, having their own list of server indexes and read + * weights. If a query group list is provided in $groups, then each recognized group + * will be tried, left-to-right, until server index selection succeeds or all groups + * have been tried, in which case the generic group will be tried. + * - DB_MASTER: the master server index will be used; the same as getWriterIndex(). + * The value of $groups should be [] when using this server index. + * - Specific server index: a positive integer can be provided to use the server with + * that index. An error will be thrown in no such server index is recognized. This + * server selection method is usually only useful for internal load balancing logic. + * The value of $groups should be [] when using a specific server index. + * + * Handles acquired by this method, getConnectionRef(), getLazyConnectionRef(), and + * getMaintenanceConnectionRef() use the same set of shared connection pools. Callers that + * get a *local* DB domain handle for the same server will share one handle for all of those + * callers using CONN_TRX_AUTOCOMMIT (via $flags) and one handle for all of those callers not + * using CONN_TRX_AUTOCOMMIT. Callers that get a *foreign* DB domain handle (via $domain) will + * share any handle that has the right CONN_TRX_AUTOCOMMIT mode and is already on the right + * DB domain. Otherwise, one of the "free for reuse" handles will be claimed or a new handle + * will be made if there are none. + * + * Handle sharing is particularly useful when callers get local DB domain (the default), + * transaction round aware (the default), DB_MASTER handles. All such callers will operate + * within a single database transaction as a consequence. Handle sharing is also useful when + * callers get local DB domain (the default), transaction round aware (the default), samely + * query grouped (the default), DB_REPLICA handles. All such callers will operate within a + * single database transaction as a consequence. + * + * Calling functions that use $domain must call reuseConnection() once the last query of the + * function is executed. This lets the load balancer share this handle with other callers + * requesting connections on different database domains. + * + * Use CONN_TRX_AUTOCOMMIT to use a separate pool of only auto-commit handles. This flag + * is ignored for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in order to avoid + * deadlocks. getServerAttributes() can be used to check such attributes beforehand. Avoid + * using IDatabase::begin() and IDatabase::commit() on such handles. If it is not possible + * to avoid using methods like IDatabase::startAtomic() and IDatabase::endAtomic(), callers + * should at least make sure that the atomic sections are closed on failure via try/catch + * and IDatabase::cancelAtomic(). + * + * @see ILoadBalancer::reuseConnection() + * @see ILoadBalancer::getServerAttributes() * * @param int $i Server index (overrides $groups) or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) or [] to use the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants - * - * @note This method throws DBAccessError if ILoadBalancer::disable() was called - * - * @throws DBError - * @return Database + * @return IDatabase|bool Live connection handle or false on failure + * @throws DBError If no live handle can be obtained and CONN_SILENCE_ERRORS is not set + * @throws DBAccessError If disable() was previously called */ public function getConnection( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Mark a foreign connection as being available for reuse under a different DB domain + * Mark a live handle as being available for reuse under a different database domain * - * This mechanism is reference-counted, and must be called the same number of times - * as getConnection() to work. + * This mechanism is reference-counted, and must be called the same number of times as + * getConnection() to work. Never call this on handles acquired via getConnectionRef(), + * getLazyConnectionRef(), and getMaintenanceConnectionRef(), as they already manage + * the logic of calling this method when they fall out of scope in PHP. + * + * @see ILoadBalancer::getConnection() * * @param IDatabase $conn - * @throws InvalidArgumentException + * @throws LogicException */ public function reuseConnection( IDatabase $conn ); /** - * Get a database connection handle reference - * - * The handle's methods simply wrap those of a Database handle + * Get a live database handle reference for a real or virtual (DB_MASTER/DB_REPLICA) server index * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) or [] to use the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Get a database connection handle reference without connecting yet + * Get a database handle reference for a real or virtual (DB_MASTER/DB_REPLICA) server index * - * The handle's methods simply wrap those of a Database handle + * The handle's methods simply proxy to those of an underlying IDatabase handle which + * takes care of the actual connection and query logic. * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) or [] to use the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Get a maintenance database connection handle reference for migrations and schema changes + * Get a live database handle for a real or virtual (DB_MASTER/DB_REPLICA) server index + * that can be used for data migrations and schema changes * - * The handle's methods simply wrap those of a Database handle + * The handle's methods simply proxy to those of an underlying IDatabase handle which + * takes care of the actual connection and query logic. * * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING - * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() + * (e.g. sqlite) in order to avoid deadlocks. getServerAttributes() * can be used to check such flags beforehand. Avoid the use of begin() or startAtomic() * on any CONN_TRX_AUTOCOMMIT connections. * * @see ILoadBalancer::getConnection() for parameter information * * @param int $i Server index or DB_MASTER/DB_REPLICA - * @param array|string|bool $groups Query group(s), or false for the generic reader - * @param string|bool $domain Domain ID, or false for the current domain + * @param string[]|string $groups Query group(s) or [] to use the default group + * @param string|bool $domain DB domain ID or false for the local domain * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return MaintainableDBConnRef */ public function getMaintenanceConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); /** - * Open a connection to the server given by the specified index - * - * The index must be an actual index into the array. If a connection to the server is - * already open and not considered an "in use" foreign connection, this simply returns it. - * - * Avoid using CONN_TRX_AUTOCOMMIT for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) - * in order to avoid deadlocks. ILoadBalancer::getServerAttributes() can be used to check - * such flags beforehand. + * Get the server index of the master server * - * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must - * also call ILoadBalancer::reuseConnection() on the handle when finished using it. - * In all other cases, this is not necessary, though not harmful either. - * Avoid the use of begin() or startAtomic() on any such connections. - * - * @note This method throws DBAccessError if ILoadBalancer::disable() was called - * - * @param int $i Server index (does not support DB_MASTER/DB_REPLICA) - * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) - * @return Database|bool Returns false on errors - * @throws DBAccessError - */ - public function openConnection( $i, $domain = false, $flags = 0 ); - - /** * @return int */ public function getWriterIndex(); /** - * Returns true if the specified index is a valid server index + * Get the number of servers defined in configuration * - * @param int $i - * @return bool + * @return int */ - public function haveIndex( $i ); + public function getServerCount(); /** - * Returns true if the specified index is valid and has non-zero load + * Whether there are any replica servers configured * - * @param int $i - * @return bool + * This counts both servers using streaming replication from the master server and + * servers that just have a clone of the static dataset found on the master server + * + * @return int + * @since 1.34 */ - public function isNonZeroLoad( $i ); + public function hasReplicaServers(); /** - * Get the number of defined servers (not the number of open connections) + * Whether any replica servers use streaming replication from the master server + * + * Generally this is one less than getServerCount(), though it might otherwise + * return a lower number if some of the servers are configured with "is static". + * That flag is used when both the server has no active replication setup and the + * dataset is either read-only or occasionally updated out-of-band. For example, + * a script might import a new geographic information dataset each week by writing + * it to each server and later directing the application to use the new version. + * + * It is possible for some replicas to be configured with "is static" but not + * others, though it generally should either be set for all or none of the replicas. + * + * If this returns zero, this means that there is generally no reason to execute + * replication wait logic for session consistency and lag reduction. * * @return int + * @since 1.34 */ - public function getServerCount(); + public function hasStreamingReplicaServers(); /** * Get the host name or IP address of the server with the specified index @@ -361,7 +408,7 @@ interface ILoadBalancer { public function getServerName( $i ); /** - * Return the server info structure for a given index, or false if the index is invalid. + * Return the server info structure for a given index or false if the index is invalid. * @param int $i * @return array|bool * @since 1.31 @@ -414,18 +461,21 @@ interface ILoadBalancer { /** * Commit transactions on all open connections * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function commitAll( $fname = __METHOD__ ); + public function commitAll( $fname = __METHOD__, $owner = null ); /** * Run pre-commit callbacks and defer execution of post-commit callbacks * * Use this only for mutli-database commits * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return int Number of pre-commit callbacks run (since 1.32) */ - public function finalizeMasterChanges(); + public function finalizeMasterChanges( $fname = __METHOD__, $owner = null ); /** * Perform all pre-commit checks for things like replication safety @@ -434,9 +484,11 @@ interface ILoadBalancer { * * @param array $options Includes: * - maxWriteDuration : max write query duration time in seconds + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBTransactionError */ - public function approveMasterChanges( array $options ); + public function approveMasterChanges( array $options, $fname, $owner = null ); /** * Flush any master transaction snapshots and set DBO_TRX (if DBO_DEFAULT is set) @@ -447,38 +499,45 @@ interface ILoadBalancer { * - commitAll() * This allows for custom transaction rounds from any outer transaction scope. * - * @param string $fname + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function beginMasterChanges( $fname = __METHOD__ ); + public function beginMasterChanges( $fname = __METHOD__, $owner = null ); /** * Issue COMMIT on all open master connections to flush changes and view snapshots * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function commitMasterChanges( $fname = __METHOD__ ); + public function commitMasterChanges( $fname = __METHOD__, $owner = null ); /** * Consume and run all pending post-COMMIT/ROLLBACK callbacks and commit dangling transactions * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return Exception|null The first exception or null if there were none */ - public function runMasterTransactionIdleCallbacks(); + public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null ); /** * Run all recurring post-COMMIT/ROLLBACK listener callbacks * + * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @return Exception|null The first exception or null if there were none */ - public function runMasterTransactionListenerCallbacks(); + public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null ); /** * Issue ROLLBACK only on master, only if queries were done on connection * @param string $fname Caller name + * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID) * @throws DBExpectedError */ - public function rollbackMasterChanges( $fname = __METHOD__ ); + public function rollbackMasterChanges( $fname = __METHOD__, $owner = null ); /** * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshots @@ -531,7 +590,7 @@ interface ILoadBalancer { /** * @note This method will trigger a DB connection if not yet done - * @param string|bool $domain Domain ID, or false for the current domain + * @param string|bool $domain DB domain ID or false for the local domain * @return bool Whether the database for generic connections this request is highly "lagged" */ public function getLaggedReplicaMode( $domain = false ); @@ -548,7 +607,7 @@ interface ILoadBalancer { /** * @note This method may trigger a DB connection if not yet done - * @param string|bool $domain Domain ID, or false for the current domain + * @param string|bool $domain DB domain ID or false for the local domain * @param IDatabase|null $conn DB master connection; used to avoid loops [optional] * @return string|bool Reason the master is read-only or false if it is not */ @@ -588,13 +647,13 @@ interface ILoadBalancer { public function forEachOpenReplicaConnection( $callback, array $params = [] ); /** - * Get the hostname and lag time of the most-lagged replica DB + * Get the hostname and lag time of the most-lagged replica server * * This is useful for maintenance scripts that need to throttle their updates. * May attempt to open connections to replica DBs on the default DB. If there is * no lag, the maximum lag will be reported as -1. * - * @param bool|string $domain Domain ID, or false for the default database + * @param bool|string $domain Domain ID or false for the default database * @return array ( host, max lag, index of max lagged host ) */ public function getMaxLag( $domain = false ); @@ -611,22 +670,6 @@ interface ILoadBalancer { */ public function getLagTimes( $domain = false ); - /** - * Get the lag in seconds for a given connection, or zero if this load - * balancer does not have replication enabled. - * - * This should be used in preference to Database::getLag() in cases where - * replication may not be in use, since there is no way to determine if - * replication is in use at the connection level without running - * potentially restricted queries such as SHOW SLAVE STATUS. Using this - * function instead of Database::getLag() avoids a fatal error in this - * case on many installations. - * - * @param IDatabase $conn - * @return int|bool Returns false on error - */ - public function safeGetLag( IDatabase $conn ); - /** * Wait for a replica DB to reach a specified master position * @@ -636,8 +679,9 @@ interface ILoadBalancer { * @param DBMasterPos|bool $pos Master position; default: current position * @param int $timeout Timeout in seconds [optional] * @return bool Success + * @since 1.34 */ - public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ); + public function waitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ); /** * Set a callback via IDatabase::setTransactionListener() on diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancer.php b/includes/libs/rdbms/loadbalancer/LoadBalancer.php index da5382a0e9..b640dc024b 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancer.php @@ -28,6 +28,7 @@ use BagOStuff; use EmptyBagOStuff; use WANObjectCache; use ArrayUtils; +use LogicException; use UnexpectedValueException; use InvalidArgumentException; use RuntimeException; @@ -64,7 +65,7 @@ class LoadBalancer implements ILoadBalancer { /** @var callable Deprecation logger */ private $deprecationLogger; - /** @var DatabaseDomain Local Domain ID and default for selectDB() calls */ + /** @var DatabaseDomain Local DB domain ID and default for selectDB() calls */ private $localDomain; /** @var Database[][][] Map of (connection category => server index => IDatabase[]) */ @@ -73,7 +74,7 @@ class LoadBalancer implements ILoadBalancer { /** @var array[] Map of (server index => server config array) */ private $servers; /** @var float[] Map of (server index => weight) */ - private $loads; + private $genericLoads; /** @var array[] Map of (group => server index => weight) */ private $groupLoads; /** @var bool Whether to disregard replica DB lag as a factor in replica DB selection */ @@ -82,10 +83,12 @@ class LoadBalancer implements ILoadBalancer { private $waitTimeout; /** @var array The LoadMonitor configuration */ private $loadMonitorConfig; - /** @var string Alternate ID string for the domain instead of DatabaseDomain::getId() */ + /** @var string Alternate local DB domain instead of DatabaseDomain::getId() */ private $localDomainIdAlias; - /** @var int */ + /** @var int Amount of replication lag, in seconds, that is considered "high" */ private $maxLag; + /** @var string|bool The query group list to be used by default */ + private $defaultGroup; /** @var string Current server name */ private $hostname; @@ -101,35 +104,36 @@ class LoadBalancer implements ILoadBalancer { /** @var array[] Map of (name => callable) */ private $trxRecurringCallbacks = []; - /** @var Database DB connection object that caused a problem */ + /** @var Database Connection handle that caused a problem */ private $errorConnection; - /** @var int The generic (not query grouped) replica DB index (of $mServers) */ - private $readIndex; - /** @var bool|DBMasterPos False if not set */ + /** @var int The generic (not query grouped) replica server index */ + private $genericReadIndex = -1; + /** @var int[] The group replica server indexes keyed by group */ + private $readIndexByGroup = []; + /** @var bool|DBMasterPos Replication sync position or false if not set */ private $waitForPos; /** @var bool Whether the generic reader fell back to a lagged replica DB */ private $laggedReplicaMode = false; /** @var bool Whether the generic reader fell back to a lagged replica DB */ private $allReplicasDownMode = false; - /** @var string The last DB selection or connection error */ + /** @var string The last DB domain selection or connection error */ private $lastError = 'Unknown error'; - /** @var string|bool Reason the LB is read-only or false if not */ + /** @var string|bool Reason this instance is read-only or false if not */ private $readOnlyReason = false; - /** @var int Total connections opened */ - private $connsOpened = 0; + /** @var int Total number of new connections ever made with this instance */ + private $connectionCounter = 0; /** @var bool */ private $disabled = false; /** @var bool Whether any connection has been attempted yet */ private $connectionAttempted = false; + /** @var int|null An integer ID of the managing LBFactory instance or null */ + private $ownerId; /** @var string|bool String if a requested DBO_TRX transaction round is active */ private $trxRoundId = false; /** @var string Stage of the current transaction round in the transaction round life-cycle */ private $trxRoundStage = self::ROUND_CURSORY; - /** @var string|null */ - private $defaultGroup = null; - /** @var int Warn when this many connection are held */ const CONN_HELD_WARN_THRESHOLD = 10; @@ -162,15 +166,29 @@ class LoadBalancer implements ILoadBalancer { const ROUND_ERROR = 'error'; public function __construct( array $params ) { - if ( !isset( $params['servers'] ) ) { - throw new InvalidArgumentException( __CLASS__ . ': missing servers parameter' ); + if ( !isset( $params['servers'] ) || !count( $params['servers'] ) ) { + throw new InvalidArgumentException( 'Missing or empty "servers" parameter' ); } - $this->servers = $params['servers']; - foreach ( $this->servers as $i => $server ) { + + $listKey = -1; + $this->servers = []; + $this->genericLoads = []; + foreach ( $params['servers'] as $i => $server ) { + if ( ++$listKey !== $i ) { + throw new UnexpectedValueException( 'List expected for "servers" parameter' ); + } if ( $i == 0 ) { - $this->servers[$i]['master'] = true; + $server['master'] = true; } else { - $this->servers[$i]['replica'] = true; + $server['replica'] = true; + } + $this->servers[$i] = $server; + + $this->genericLoads[$i] = $server['load']; + if ( isset( $server['groupLoads'] ) ) { + foreach ( $server['groupLoads'] as $group => $ratio ) { + $this->groupLoads[$group][$i] = $ratio; + } } } @@ -181,18 +199,7 @@ class LoadBalancer implements ILoadBalancer { $this->waitTimeout = $params['waitTimeout'] ?? self::MAX_WAIT_DEFAULT; - $this->readIndex = -1; - $this->conns = [ - // Connection were transaction rounds may be applied - self::KEY_LOCAL => [], - self::KEY_FOREIGN_INUSE => [], - self::KEY_FOREIGN_FREE => [], - // Auto-committing counterpart connections that ignore transaction rounds - self::KEY_LOCAL_NOROUND => [], - self::KEY_FOREIGN_INUSE_NOROUND => [], - self::KEY_FOREIGN_FREE_NOROUND => [] - ]; - $this->loads = []; + $this->conns = self::newTrackedConnectionsArray(); $this->waitForPos = false; $this->allowLagged = false; @@ -205,18 +212,6 @@ class LoadBalancer implements ILoadBalancer { $this->loadMonitorConfig = $params['loadMonitor'] ?? [ 'class' => 'LoadMonitorNull' ]; $this->loadMonitorConfig += [ 'lagWarnThreshold' => $this->maxLag ]; - foreach ( $params['servers'] as $i => $server ) { - $this->loads[$i] = $server['load']; - if ( isset( $server['groupLoads'] ) ) { - foreach ( $server['groupLoads'] as $group => $ratio ) { - if ( !isset( $this->groupLoads[$group] ) ) { - $this->groupLoads[$group] = []; - } - $this->groupLoads[$group][$i] = $ratio; - } - } - } - $this->srvCache = $params['srvCache'] ?? new EmptyBagOStuff(); $this->wanCache = $params['wanCache'] ?? WANObjectCache::newEmpty(); $this->profiler = $params['profiler'] ?? null; @@ -249,7 +244,21 @@ class LoadBalancer implements ILoadBalancer { } } - $this->defaultGroup = $params['defaultGroup'] ?? null; + $this->defaultGroup = $params['defaultGroup'] ?? self::GROUP_GENERIC; + $this->ownerId = $params['ownerId'] ?? null; + } + + private static function newTrackedConnectionsArray() { + return [ + // Connection were transaction rounds may be applied + self::KEY_LOCAL => [], + self::KEY_FOREIGN_INUSE => [], + self::KEY_FOREIGN_FREE => [], + // Auto-committing counterpart connections that ignore transaction rounds + self::KEY_LOCAL_NOROUND => [], + self::KEY_FOREIGN_INUSE_NOROUND => [], + self::KEY_FOREIGN_FREE_NOROUND => [] + ]; } public function getLocalDomainID() { @@ -257,10 +266,82 @@ class LoadBalancer implements ILoadBalancer { } public function resolveDomainID( $domain ) { - return ( $domain !== false ) ? (string)$domain : $this->getLocalDomainID(); + if ( $domain === $this->localDomainIdAlias || $domain === false ) { + // Local connection requested via some backwards-compatibility domain alias + return $this->getLocalDomainID(); + } + + return (string)$domain; + } + + /** + * @param string[]|string|bool $groups Query group list or false for the default + * @param int $i Specific server index or DB_MASTER/DB_REPLICA + * @return string[]|bool[] Query group list + */ + private function resolveGroups( $groups, $i ) { + if ( $groups === false ) { + $resolvedGroups = [ $this->defaultGroup ]; + } elseif ( is_string( $groups ) ) { + $resolvedGroups = [ $groups ]; + } elseif ( is_array( $groups ) ) { + $resolvedGroups = $groups ?: [ $this->defaultGroup ]; + } else { + throw new InvalidArgumentException( "Invalid query groups provided" ); + } + + if ( $groups && $i > 0 ) { + $groupList = implode( ', ', $groups ); + throw new LogicException( "Got query groups ($groupList) with a server index (#$i)" ); + } + + return $resolvedGroups; + } + + /** + * @param int $flags + * @return bool + */ + private function sanitizeConnectionFlags( $flags ) { + if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) === self::CONN_TRX_AUTOCOMMIT ) { + // Assuming all servers are of the same type (or similar), which is overwhelmingly + // the case, use the master server information to get the attributes. The information + // for $i cannot be used since it might be DB_REPLICA, which might require connection + // attempts in order to be resolved into a real server index. + $attributes = $this->getServerAttributes( $this->getWriterIndex() ); + if ( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ) { + // Callers sometimes want to (a) escape REPEATABLE-READ stateness without locking + // rows (e.g. FOR UPDATE) or (b) make small commits during a larger transactions + // to reduce lock contention. None of these apply for sqlite and using separate + // connections just causes self-deadlocks. + $flags &= ~self::CONN_TRX_AUTOCOMMIT; + $this->connLogger->info( __METHOD__ . + ': ignoring CONN_TRX_AUTOCOMMIT to avoid deadlocks.' ); + } + } + + return $flags; } /** + * @param IDatabase $conn + * @param int $flags + * @throws DBUnexpectedError + */ + private function enforceConnectionFlags( IDatabase $conn, $flags ) { + if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ) { + if ( $conn->trxLevel() ) { // sanity + throw new DBUnexpectedError( + $conn, + 'Handle requested with CONN_TRX_AUTOCOMMIT yet it has a transaction' + ); + } + + $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode + } + } + + /** * Get a LoadMonitor instance * * @return ILoadMonitor @@ -297,7 +378,7 @@ class LoadBalancer implements ILoadBalancer { # Unset excessively lagged servers foreach ( $lags as $i => $lag ) { - if ( $i != 0 ) { + if ( $i !== $this->getWriterIndex() ) { # How much lag this server nominally is allowed to have $maxServerLag = $this->servers[$i]['max lag'] ?? $this->maxLag; // default # Constrain that futher by $maxLag argument @@ -341,16 +422,64 @@ class LoadBalancer implements ILoadBalancer { return ArrayUtils::pickRandom( $loads ); } + /** + * Get the server index to use for a specified server index and query group list + * + * @param int $i Specific server index or DB_MASTER/DB_REPLICA + * @param string[]|bool[] $groups Resolved query group list (non-empty) + * @param string|bool $domain + * @return int A specific server index (replica DBs are checked for connectivity) + */ + private function getConnectionIndex( $i, array $groups, $domain ) { + if ( $i === self::DB_MASTER ) { + $i = $this->getWriterIndex(); + } elseif ( $i === self::DB_REPLICA ) { + // Find an available server in any of the query groups (in order) + foreach ( $groups as $group ) { + $groupIndex = $this->getReaderIndex( $group, $domain ); + if ( $groupIndex !== false ) { + $i = $groupIndex; // group connection succeeded + break; + } + } + } elseif ( !isset( $this->servers[$i] ) ) { + throw new UnexpectedValueException( "Invalid server index index #$i" ); + } + + if ( $i === self::DB_REPLICA ) { + // No specific server was yet found + $this->lastError = 'Unknown error'; // set here in case of worse failure + // Either make one last connection attempt or give up + $i = in_array( $this->defaultGroup, $groups, true ) + // Connection attempt already included the default query group; give up + ? false + // Connection attempt was for other query groups; try the default one + : $this->getReaderIndex( $this->defaultGroup, $domain ); + + if ( $i === false ) { + // Still coundn't find a working non-zero read load server + $this->lastError = 'No working replica DB server: ' . $this->lastError; + $this->reportConnectionError(); + return null; // unreachable due to exception + } + } + + return $i; + } + public function getReaderIndex( $group = false, $domain = false ) { - if ( count( $this->servers ) == 1 ) { + if ( $this->getServerCount() == 1 ) { // Skip the load balancing if there's only one server return $this->getWriterIndex(); - } elseif ( $group === false && $this->readIndex >= 0 ) { - // Shortcut if the generic reader index was already cached - return $this->readIndex; } - if ( $group !== false ) { + $index = $this->getExistingReaderIndex( $group ); + if ( $index >= 0 ) { + // A reader index was already selected and "waitForPos" was handled + return $index; + } + + if ( $group !== self::GROUP_GENERIC ) { // Use the server weight array for this load group if ( isset( $this->groupLoads[$group] ) ) { $loads = $this->groupLoads[$group]; @@ -362,36 +491,32 @@ class LoadBalancer implements ILoadBalancer { } } else { // Use the generic load group - $loads = $this->loads; + $loads = $this->genericLoads; } // Scale the configured load ratios according to each server's load and state $this->getLoadMonitor()->scaleLoads( $loads, $domain ); // Pick a server to use, accounting for weights, load, lag, and "waitForPos" + $this->lazyLoadReplicationPositions(); // optimizes server candidate selection list( $i, $laggedReplicaMode ) = $this->pickReaderIndex( $loads, $domain ); if ( $i === false ) { - // Replica DB connection unsuccessful + // DB connection unsuccessful return false; } - if ( $this->waitForPos && $i != $this->getWriterIndex() ) { - // Before any data queries are run, wait for the server to catch up to the - // specified position. This is used to improve session consistency. Note that - // when LoadBalancer::waitFor() sets "waitForPos", the waiting triggers here, - // so update laggedReplicaMode as needed for consistency. - if ( !$this->doWait( $i ) ) { - $laggedReplicaMode = true; - } + // If data seen by queries is expected to reflect the transactions committed as of + // or after a given replication position then wait for the DB to apply those changes + if ( $this->waitForPos && $i !== $this->getWriterIndex() && !$this->doWait( $i ) ) { + // Data will be outdated compared to what was expected + $laggedReplicaMode = true; } - if ( $this->readIndex <= 0 && $this->loads[$i] > 0 && $group === false ) { - // Cache the generic reader index for future ungrouped DB_REPLICA handles - $this->readIndex = $i; - // Record if the generic reader index is in "lagged replica DB" mode - if ( $laggedReplicaMode ) { - $this->laggedReplicaMode = true; - } + // Cache the reader index for future DB_REPLICA handles + $this->setExistingReaderIndex( $group, $i ); + // Record whether the generic reader index is in "lagged replica DB" mode + if ( $group === self::GROUP_GENERIC && $laggedReplicaMode ) { + $this->laggedReplicaMode = true; } $serverName = $this->getServerName( $i ); @@ -400,6 +525,40 @@ class LoadBalancer implements ILoadBalancer { return $i; } + /** + * Get the server index chosen by the load balancer for use with the given query group + * + * @param string|bool $group Query group; use false for the generic group + * @return int Server index or -1 if none was chosen + */ + protected function getExistingReaderIndex( $group ) { + if ( $group === self::GROUP_GENERIC ) { + $index = $this->genericReadIndex; + } else { + $index = $this->readIndexByGroup[$group] ?? -1; + } + + return $index; + } + + /** + * Set the server index chosen by the load balancer for use with the given query group + * + * @param string|bool $group Query group; use false for the generic group + * @param int $index The index of a specific server + */ + private function setExistingReaderIndex( $group, $index ) { + if ( $index < 0 ) { + throw new UnexpectedValueException( "Cannot set a negative read server index" ); + } + + if ( $group === self::GROUP_GENERIC ) { + $this->genericReadIndex = $index; + } else { + $this->readIndexByGroup[$group] = $index; + } + } + /** * @param array $loads List of server weights * @param string|bool $domain @@ -407,7 +566,7 @@ class LoadBalancer implements ILoadBalancer { */ private function pickReaderIndex( array $loads, $domain = false ) { if ( $loads === [] ) { - throw new InvalidArgumentException( "Empty server array given to LoadBalancer" ); + throw new InvalidArgumentException( "Server configuration array is empty" ); } /** @var int|bool $i Index of selected server */ @@ -423,6 +582,7 @@ class LoadBalancer implements ILoadBalancer { } else { $i = false; if ( $this->waitForPos && $this->waitForPos->asOfTime() ) { + $this->replLogger->debug( __METHOD__ . ": replication positions detected" ); // "chronologyCallback" sets "waitForPos" for session consistency. // This triggers doWait() after connect, so it's especially good to // avoid lagged servers so as to avoid excessive delay in that method. @@ -434,7 +594,7 @@ class LoadBalancer implements ILoadBalancer { // Any server with less lag than it's 'max lag' param is preferable $i = $this->getRandomNonLagged( $currentLoads, $domain ); } - if ( $i === false && count( $currentLoads ) != 0 ) { + if ( $i === false && count( $currentLoads ) ) { // All replica DBs lagged. Switch to read-only mode $this->replLogger->error( __METHOD__ . ": all replica DBs lagged. Switch to read-only mode" ); @@ -455,7 +615,7 @@ class LoadBalancer implements ILoadBalancer { $serverName = $this->getServerName( $i ); $this->connLogger->debug( __METHOD__ . ": Using reader #$i: $serverName..." ); - $conn = $this->openConnection( $i, $domain ); + $conn = $this->getConnection( $i, [], $domain, self::CONN_SILENCE_ERRORS ); if ( !$conn ) { $this->connLogger->warning( __METHOD__ . ": Failed connecting to $i/$domain" ); unset( $currentLoads[$i] ); // avoid this server next iteration @@ -486,10 +646,10 @@ class LoadBalancer implements ILoadBalancer { try { $this->waitForPos = $pos; // If a generic reader connection was already established, then wait now - $i = $this->readIndex; - if ( ( $i > 0 ) && !$this->doWait( $i ) ) { + if ( $this->genericReadIndex > 0 && !$this->doWait( $this->genericReadIndex ) ) { $this->laggedReplicaMode = true; } + // Otherwise, wait until a connection is established in getReaderIndex() } finally { // Restore the older position if it was higher since this is used for lag-protection $this->setWaitForPositionIfHigher( $oldPos ); @@ -501,10 +661,10 @@ class LoadBalancer implements ILoadBalancer { try { $this->waitForPos = $pos; - $i = $this->readIndex; + $i = $this->genericReadIndex; if ( $i <= 0 ) { // Pick a generic replica DB if there isn't one yet - $readLoads = $this->loads; + $readLoads = $this->genericLoads; unset( $readLoads[$this->getWriterIndex()] ); // replica DBs only $readLoads = array_filter( $readLoads ); // with non-zero load $i = ArrayUtils::pickRandom( $readLoads ); @@ -529,11 +689,11 @@ class LoadBalancer implements ILoadBalancer { $oldPos = $this->waitForPos; try { $this->waitForPos = $pos; - $serverCount = count( $this->servers ); + $serverCount = $this->getServerCount(); $ok = true; for ( $i = 1; $i < $serverCount; $i++ ) { - if ( $this->loads[$i] > 0 ) { + if ( $this->genericLoads[$i] > 0 ) { $start = microtime( true ); $ok = $this->doWait( $i, true, $timeout ) && $ok; $timeout -= intval( microtime( true ) - $start ); @@ -567,7 +727,9 @@ class LoadBalancer implements ILoadBalancer { $i = ( $i === self::DB_MASTER ) ? $this->getWriterIndex() : $i; $autocommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); + $conn = false; foreach ( $this->conns as $connsByServer ) { + // Get the connection array server indexes to inspect if ( $i === self::DB_REPLICA ) { $indexes = array_keys( $connsByServer ); } else { @@ -575,25 +737,54 @@ class LoadBalancer implements ILoadBalancer { } foreach ( $indexes as $index ) { - foreach ( $connsByServer[$index] as $conn ) { - if ( !$conn->isOpen() ) { - continue; // some sort of error occured? - } - if ( !$autocommit || $conn->getLBInfo( 'autoCommitOnly' ) ) { - return $conn; - } + $conn = $this->pickAnyOpenConnection( $connsByServer[$index], $autocommit ); + if ( $conn ) { + break; } } } - return false; + if ( $conn ) { + $this->enforceConnectionFlags( $conn, $flags ); + } + + return $conn; + } + + /** + * @param IDatabase[] $candidateConns + * @param bool $autocommit Whether to only look for auto-commit connections + * @return IDatabase|false An appropriate open connection or false if none found + */ + private function pickAnyOpenConnection( $candidateConns, $autocommit ) { + $conn = false; + + foreach ( $candidateConns as $candidateConn ) { + if ( !$candidateConn->isOpen() ) { + continue; // some sort of error occured? + } elseif ( + $autocommit && + ( + // Connection is transaction round aware + !$candidateConn->getLBInfo( 'autoCommitOnly' ) || + // Some sort of error left a transaction open? + $candidateConn->trxLevel() + ) + ) { + continue; // some sort of error left a transaction open? + } + + $conn = $candidateConn; + } + + return $conn; } /** - * Wait for a given replica DB to catch up to the master pos stored in $this + * Wait for a given replica DB to catch up to the master pos stored in "waitForPos" * @param int $index Server index * @param bool $open Check the server even if a new connection has to be made - * @param int|null $timeout Max seconds to wait; default is "waitTimeout" given to __construct() + * @param int|null $timeout Max seconds to wait; default is "waitTimeout" * @return bool */ protected function doWait( $index, $open = false, $timeout = null ) { @@ -627,20 +818,20 @@ class LoadBalancer implements ILoadBalancer { ); return false; - } else { - $conn = $this->openConnection( $index, self::DOMAIN_ANY ); - if ( !$conn ) { - $this->replLogger->warning( - __METHOD__ . ': failed to connect to {dbserver}', - [ 'dbserver' => $server ] - ); + } + // Open a temporary new connection in order to wait for replication + $conn = $this->getConnection( $index, [], self::DOMAIN_ANY, self::CONN_SILENCE_ERRORS ); + if ( !$conn ) { + $this->replLogger->warning( + __METHOD__ . ': failed to connect to {dbserver}', + [ 'dbserver' => $server ] + ); - return false; - } - // Avoid connection spam in waitForAll() when connections - // are made just for the sake of doing this lag check. - $close = true; + return false; } + // Avoid connection spam in waitForAll() when connections + // are made just for the sake of doing this lag check. + $close = true; } $this->replLogger->info( @@ -686,88 +877,48 @@ class LoadBalancer implements ILoadBalancer { } public function getConnection( $i, $groups = [], $domain = false, $flags = 0 ) { - if ( $i === null || $i === false ) { - throw new InvalidArgumentException( 'Attempt to call ' . __METHOD__ . - ' with invalid server index' ); - } - - if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) { - $domain = false; // local connection requested - } - - if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) === self::CONN_TRX_AUTOCOMMIT ) { - // Assuming all servers are of the same type (or similar), which is overwhelmingly - // the case, use the master server information to get the attributes. The information - // for $i cannot be used since it might be DB_REPLICA, which might require connection - // attempts in order to be resolved into a real server index. - $attributes = $this->getServerAttributes( $this->getWriterIndex() ); - if ( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ) { - // Callers sometimes want to (a) escape REPEATABLE-READ stateness without locking - // rows (e.g. FOR UPDATE) or (b) make small commits during a larger transactions - // to reduce lock contention. None of these apply for sqlite and using separate - // connections just causes self-deadlocks. - $flags &= ~self::CONN_TRX_AUTOCOMMIT; - $this->connLogger->info( __METHOD__ . - ': ignoring CONN_TRX_AUTOCOMMIT to avoid deadlocks.' ); - } - } - - // Check one "group" per default: the generic pool - $defaultGroups = $this->defaultGroup ? [ $this->defaultGroup ] : [ false ]; - - $groups = ( $groups === false || $groups === [] ) - ? $defaultGroups - : (array)$groups; - - $masterOnly = ( $i == self::DB_MASTER || $i == $this->getWriterIndex() ); - $oldConnsOpened = $this->connsOpened; // connections open now - - if ( $i == self::DB_MASTER ) { - $i = $this->getWriterIndex(); - } elseif ( $i == self::DB_REPLICA ) { - # Try to find an available server in any the query groups (in order) - foreach ( $groups as $group ) { - $groupIndex = $this->getReaderIndex( $group, $domain ); - if ( $groupIndex !== false ) { - $i = $groupIndex; - break; - } - } - } - - # Operation-based index - if ( $i == self::DB_REPLICA ) { - $this->lastError = 'Unknown error'; // reset error string - # Try the general server pool if $groups are unavailable. - $i = ( $groups === [ false ] ) - ? false // don't bother with this if that is what was tried above - : $this->getReaderIndex( false, $domain ); - # Couldn't find a working server in getReaderIndex()? - if ( $i === false ) { - $this->lastError = 'No working replica DB server: ' . $this->lastError; - // Throw an exception + $groups = $this->resolveGroups( $groups, $i ); + $domain = $this->resolveDomainID( $domain ); + $flags = $this->sanitizeConnectionFlags( $flags ); + $masterOnly = ( $i === self::DB_MASTER || $i === $this->getWriterIndex() ); + + // Number of connections made before getting the server index and handle + $priorConnectionsMade = $this->connectionCounter; + // Choose a server if $i is DB_MASTER/DB_REPLICA (might trigger new connections) + $serverIndex = $this->getConnectionIndex( $i, $groups, $domain ); + // Get an open connection to that server (might trigger a new connection) + $conn = $this->localDomain->equals( $domain ) + ? $this->getLocalConnection( $serverIndex, $flags ) + : $this->getForeignConnection( $serverIndex, $domain, $flags ); + // Throw an error or bail out if the connection attempt failed + if ( !( $conn instanceof IDatabase ) ) { + if ( ( $flags & self::CONN_SILENCE_ERRORS ) != self::CONN_SILENCE_ERRORS ) { $this->reportConnectionError(); - return null; // not reached } - } - # Now we have an explicit index into the servers array - $conn = $this->openConnection( $i, $domain, $flags ); - if ( !$conn ) { - // Throw an exception - $this->reportConnectionError(); - return null; // not reached + return false; } - # Profile any new connections that happen - if ( $this->connsOpened > $oldConnsOpened ) { + // Profile any new connections caused by this method + if ( $this->connectionCounter > $priorConnectionsMade ) { $host = $conn->getServer(); $dbname = $conn->getDBname(); $this->trxProfiler->recordConnection( $host, $dbname, $masterOnly ); } - if ( $masterOnly ) { - # Make master-requested DB handles inherit any read-only mode setting + if ( !$conn->isOpen() ) { + // Connection was made but later unrecoverably lost for some reason. + // Do not return a handle that will just throw exceptions on use, + // but let the calling code (e.g. getReaderIndex) try another server. + $this->errorConnection = $conn; + return false; + } + + $this->enforceConnectionFlags( $conn, $flags ); + if ( $serverIndex === $this->getWriterIndex() ) { + // If the load balancer is read-only, perhaps due to replication lag, then master + // DB handles will reflect that. Note that Database::assertIsWritableMaster() takes + // care of replica DB handles whereas getReadOnlyReason() would cause infinite loops. $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $domain, $conn ) ); } @@ -794,7 +945,7 @@ class LoadBalancer implements ILoadBalancer { // Database instance to this method. Any caller passing in a DBConnRef is broken. $this->connLogger->error( __METHOD__ . ": got DBConnRef instance.\n" . - ( new RuntimeException() )->getTraceAsString() ); + ( new LogicException() )->getTraceAsString() ); return; } @@ -813,11 +964,11 @@ class LoadBalancer implements ILoadBalancer { $domain = $conn->getDomainID(); if ( !isset( $this->conns[$connInUseKey][$serverIndex][$domain] ) ) { - throw new InvalidArgumentException( __METHOD__ . - ": connection $serverIndex/$domain not found; it may have already been freed." ); + throw new InvalidArgumentException( + "Connection $serverIndex/$domain not found; it may have already been freed" ); } elseif ( $this->conns[$connInUseKey][$serverIndex][$domain] !== $conn ) { - throw new InvalidArgumentException( __METHOD__ . - ": connection $serverIndex/$domain mismatched; it may have already been freed." ); + throw new InvalidArgumentException( + "Connection $serverIndex/$domain mismatched; it may have already been freed" ); } $conn->setLBInfo( 'foreignPoolRefCount', --$refCount ); @@ -866,53 +1017,15 @@ class LoadBalancer implements ILoadBalancer { : self::DB_REPLICA; } + /** + * @param int $i + * @param bool $domain + * @param int $flags + * @return Database|bool Live database handle or false on failure + * @deprecated Since 1.34 Use getConnection() instead + */ public function openConnection( $i, $domain = false, $flags = 0 ) { - if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) { - $domain = false; // local connection requested - } - - if ( !$this->connectionAttempted && $this->chronologyCallback ) { - $this->connLogger->debug( __METHOD__ . ': calling initLB() before first connection.' ); - // Load any "waitFor" positions before connecting so that doWait() is triggered - $this->connectionAttempted = true; - ( $this->chronologyCallback )( $this ); - } - - // Check if an auto-commit connection is being requested. If so, it will not reuse the - // main set of DB connections but rather its own pool since: - // a) those are usually set to implicitly use transaction rounds via DBO_TRX - // b) those must support the use of explicit transaction rounds via beginMasterChanges() - $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); - - if ( $domain !== false ) { - // Connection is to a foreign domain - $conn = $this->openForeignConnection( $i, $domain, $flags ); - } else { - // Connection is to the local domain - $conn = $this->openLocalConnection( $i, $flags ); - } - - if ( $conn instanceof IDatabase && !$conn->isOpen() ) { - // Connection was made but later unrecoverably lost for some reason. - // Do not return a handle that will just throw exceptions on use, - // but let the calling code (e.g. getReaderIndex) try another server. - // See DatabaseMyslBase::ping() for how this can happen. - $this->errorConnection = $conn; - $conn = false; - } - - if ( $autoCommit && $conn instanceof IDatabase ) { - if ( $conn->trxLevel() ) { // sanity - throw new DBUnexpectedError( - $conn, - __METHOD__ . ': CONN_TRX_AUTOCOMMIT handle has a transaction.' - ); - } - - $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode - } - - return $conn; + return $this->getConnection( $i, [], $domain, $flags | self::CONN_SILENCE_ERRORS ); } /** @@ -927,18 +1040,17 @@ class LoadBalancer implements ILoadBalancer { * @param int $flags Class CONN_* constant bitfield * @return Database */ - private function openLocalConnection( $i, $flags = 0 ) { + private function getLocalConnection( $i, $flags = 0 ) { + // Connection handles required to be in auto-commit mode use a separate connection + // pool since the main pool is effected by implicit and explicit transaction rounds $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); $connKey = $autoCommit ? self::KEY_LOCAL_NOROUND : self::KEY_LOCAL; if ( isset( $this->conns[$connKey][$i][0] ) ) { $conn = $this->conns[$connKey][$i][0]; } else { - if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { - throw new InvalidArgumentException( "No server with index '$i'." ); - } // Open a new connection - $server = $this->servers[$i]; + $server = $this->getServerInfoStrict( $i ); $server['serverIndex'] = $i; $server['autoCommitOnly'] = $autoCommit; $conn = $this->reallyOpenConnection( $server, $this->localDomain ); @@ -962,7 +1074,7 @@ class LoadBalancer implements ILoadBalancer { ) { throw new UnexpectedValueException( "Got connection to '{$conn->getDomainID()}', " . - "but expected local domain ('{$this->localDomain}')." ); + "but expected local domain ('{$this->localDomain}')" ); } return $conn; @@ -990,8 +1102,10 @@ class LoadBalancer implements ILoadBalancer { * @return Database|bool Returns false on connection error * @throws DBError When database selection fails */ - private function openForeignConnection( $i, $domain, $flags = 0 ) { + private function getForeignConnection( $i, $domain, $flags = 0 ) { $domainInstance = DatabaseDomain::newFromId( $domain ); + // Connection handles required to be in auto-commit mode use a separate connection + // pool since the main pool is effected by implicit and explicit transaction rounds $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); if ( $autoCommit ) { @@ -1046,11 +1160,8 @@ class LoadBalancer implements ILoadBalancer { } if ( !$conn ) { - if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { - throw new InvalidArgumentException( "No server with index '$i'." ); - } // Open a new connection - $server = $this->servers[$i]; + $server = $this->getServerInfoStrict( $i ); $server['serverIndex'] = $i; $server['foreignPoolRefCount'] = 0; $server['foreign'] = true; @@ -1071,7 +1182,7 @@ class LoadBalancer implements ILoadBalancer { // Final sanity check to make sure the right domain is selected if ( !$domainInstance->isCompatible( $conn->getDomainID() ) ) { throw new UnexpectedValueException( - "Got connection to '{$conn->getDomainID()}', but expected '$domain'." ); + "Got connection to '{$conn->getDomainID()}', but expected '$domain'" ); } // Increment reference count $refCount = $conn->getLBInfo( 'foreignPoolRefCount' ); @@ -1092,14 +1203,9 @@ class LoadBalancer implements ILoadBalancer { * Test if the specified index represents an open connection * * @param int $index Server index - * @private * @return bool */ private function isOpen( $index ) { - if ( !is_int( $index ) ) { - return false; - } - return (bool)$this->getAnyOpenConnection( $index ); } @@ -1144,12 +1250,6 @@ class LoadBalancer implements ILoadBalancer { $masterName = $this->getServerName( $this->getWriterIndex() ); $server['clusterMasterHost'] = $masterName; - // Log when many connection are made on requests - if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) { - $this->perfLogger->warning( __METHOD__ . ": " . - "{$this->connsOpened}+ connections made (master=$masterName)" ); - } - $server['srvCache'] = $this->srvCache; // Set loggers and profilers $server['connLogger'] = $this->connLogger; @@ -1168,6 +1268,15 @@ class LoadBalancer implements ILoadBalancer { // Create a live connection object try { $db = Database::factory( $server['type'], $server ); + // Log when many connection are made on requests + ++$this->connectionCounter; + $currentConnCount = $this->getCurrentConnectionCount(); + if ( $currentConnCount >= self::CONN_HELD_WARN_THRESHOLD ) { + $this->perfLogger->warning( + __METHOD__ . ": {connections}+ connections made (master={masterdb})", + [ 'connections' => $currentConnCount, 'masterdb' => $masterName ] + ); + } } catch ( DBConnectionError $e ) { // FIXME: This is probably the ugliest thing I have ever done to // PHP. I'm half-expecting it to segfault, just out of disgust. -- TS @@ -1190,9 +1299,22 @@ class LoadBalancer implements ILoadBalancer { } } + $this->lazyLoadReplicationPositions(); // session consistency + return $db; } + /** + * Make sure that any "waitForPos" positions are loaded and available to doWait() + */ + private function lazyLoadReplicationPositions() { + if ( !$this->connectionAttempted && $this->chronologyCallback ) { + $this->connectionAttempted = true; + ( $this->chronologyCallback )( $this ); // generally calls waitFor() + $this->connLogger->debug( __METHOD__ . ': executed chronology callback.' ); + } + } + /** * @throws DBConnectionError */ @@ -1228,20 +1350,48 @@ class LoadBalancer implements ILoadBalancer { return 0; } + /** + * Returns true if the specified index is a valid server index + * + * @param int $i + * @return bool + * @deprecated Since 1.34 + */ public function haveIndex( $i ) { return array_key_exists( $i, $this->servers ); } + /** + * Returns true if the specified index is valid and has non-zero load + * + * @param int $i + * @return bool + * @deprecated Since 1.34 + */ public function isNonZeroLoad( $i ) { - return array_key_exists( $i, $this->servers ) && $this->loads[$i] != 0; + return array_key_exists( $i, $this->servers ) && $this->genericLoads[$i] != 0; } public function getServerCount() { return count( $this->servers ); } + public function hasReplicaServers() { + return ( $this->getServerCount() > 1 ); + } + + public function hasStreamingReplicaServers() { + foreach ( $this->servers as $i => $server ) { + if ( $i !== $this->getWriterIndex() && empty( $server['is static'] ) ) { + return true; + } + } + + return false; + } + public function getServerName( $i ) { - $name = $this->servers[$i]['hostName'] ?? $this->servers[$i]['host'] ?? ''; + $name = $this->servers[$i]['hostName'] ?? ( $this->servers[$i]['host'] ?? '' ); return ( $name != '' ) ? $name : 'localhost'; } @@ -1259,7 +1409,7 @@ class LoadBalancer implements ILoadBalancer { # master (however unlikely that may be), then we can fetch the position from the replica DB. $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() ); if ( !$masterConn ) { - $serverCount = count( $this->servers ); + $serverCount = $this->getServerCount(); for ( $i = 1; $i < $serverCount; $i++ ) { $conn = $this->getAnyOpenConnection( $i ); if ( $conn ) { @@ -1287,21 +1437,13 @@ class LoadBalancer implements ILoadBalancer { $conn->close(); } ); - $this->conns = [ - self::KEY_LOCAL => [], - self::KEY_FOREIGN_INUSE => [], - self::KEY_FOREIGN_FREE => [], - self::KEY_LOCAL_NOROUND => [], - self::KEY_FOREIGN_INUSE_NOROUND => [], - self::KEY_FOREIGN_FREE_NOROUND => [] - ]; - $this->connsOpened = 0; + $this->conns = self::newTrackedConnectionsArray(); } public function closeConnection( IDatabase $conn ) { if ( $conn instanceof DBConnRef ) { // Avoid calling close() but still leaving the handle in the pool - throw new RuntimeException( __METHOD__ . ': got DBConnRef instance.' ); + throw new RuntimeException( 'Cannot close DBConnRef instance; it must be shareable' ); } $serverIndex = $conn->getLBInfo( 'serverIndex' ); @@ -1316,7 +1458,6 @@ class LoadBalancer implements ILoadBalancer { $this->connLogger->debug( __METHOD__ . ": closing connection to database $i at '$host'." ); unset( $this->conns[$type][$serverIndex][$i] ); - --$this->connsOpened; break 2; } } @@ -1325,13 +1466,14 @@ class LoadBalancer implements ILoadBalancer { $conn->close(); } - public function commitAll( $fname = __METHOD__ ) { - $this->commitMasterChanges( $fname ); + public function commitAll( $fname = __METHOD__, $owner = null ) { + $this->commitMasterChanges( $fname, $owner ); $this->flushMasterSnapshots( $fname ); $this->flushReplicaSnapshots( $fname ); } - public function finalizeMasterChanges() { + public function finalizeMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( [ self::ROUND_CURSORY, self::ROUND_FINALIZED ] ); $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise @@ -1355,7 +1497,8 @@ class LoadBalancer implements ILoadBalancer { return $total; } - public function approveMasterChanges( array $options ) { + public function approveMasterChanges( array $options, $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( self::ROUND_FINALIZED ); $limit = $options['maxWriteDuration'] ?? 0; @@ -1372,7 +1515,7 @@ class LoadBalancer implements ILoadBalancer { if ( $limit > 0 && $time > $limit ) { throw new DBTransactionSizeError( $conn, - "Transaction spent $time second(s) in writes, exceeding the limit of $limit.", + "Transaction spent $time second(s) in writes, exceeding the limit of $limit", [ $time, $limit ] ); } @@ -1381,18 +1524,19 @@ class LoadBalancer implements ILoadBalancer { if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) { throw new DBTransactionError( $conn, - "A connection to the {$conn->getDBname()} database was lost before commit." + "A connection to the {$conn->getDBname()} database was lost before commit" ); } } ); $this->trxRoundStage = self::ROUND_APPROVED; } - public function beginMasterChanges( $fname = __METHOD__ ) { + public function beginMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundId !== false ) { throw new DBTransactionError( null, - "$fname: Transaction round '{$this->trxRoundId}' already started." + "$fname: Transaction round '{$this->trxRoundId}' already started" ); } $this->assertTransactionRoundStage( self::ROUND_CURSORY ); @@ -1412,7 +1556,8 @@ class LoadBalancer implements ILoadBalancer { $this->trxRoundStage = self::ROUND_CURSORY; } - public function commitMasterChanges( $fname = __METHOD__ ) { + public function commitMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); $this->assertTransactionRoundStage( self::ROUND_APPROVED ); $failures = []; @@ -1450,7 +1595,8 @@ class LoadBalancer implements ILoadBalancer { $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; } - public function runMasterTransactionIdleCallbacks() { + public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { $type = IDatabase::TRIGGER_COMMIT; } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { @@ -1519,7 +1665,8 @@ class LoadBalancer implements ILoadBalancer { return $e; } - public function runMasterTransactionListenerCallbacks() { + public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { $type = IDatabase::TRIGGER_COMMIT; } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { @@ -1546,7 +1693,9 @@ class LoadBalancer implements ILoadBalancer { return $e; } - public function rollbackMasterChanges( $fname = __METHOD__ ) { + public function rollbackMasterChanges( $fname = __METHOD__, $owner = null ) { + $this->assertOwnership( $fname, $owner ); + $restore = ( $this->trxRoundId !== false ); $this->trxRoundId = false; $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise @@ -1564,6 +1713,7 @@ class LoadBalancer implements ILoadBalancer { /** * @param string|string[] $stage + * @throws DBTransactionError */ private function assertTransactionRoundStage( $stage ) { $stages = (array)$stage; @@ -1582,6 +1732,20 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @param string $fname + * @param int|null $owner Owner ID of the caller + * @throws DBTransactionError + */ + private function assertOwnership( $fname, $owner ) { + if ( $this->ownerId !== null && $owner !== $this->ownerId ) { + throw new DBTransactionError( + null, + "$fname: LoadBalancer is owned by LBFactory #{$this->ownerId} (got '$owner')." + ); + } + } + /** * Make all DB servers with DBO_DEFAULT/DBO_TRX set join the transaction round * @@ -1683,12 +1847,17 @@ class LoadBalancer implements ILoadBalancer { } public function getLaggedReplicaMode( $domain = false ) { - // No-op if there is only one DB (also avoids recursion) - if ( !$this->laggedReplicaMode && $this->getServerCount() > 1 ) { + if ( + // Avoid recursion if there is only one DB + $this->hasStreamingReplicaServers() && + // Avoid recursion if the (non-zero load) master DB was picked for generic reads + $this->genericReadIndex !== $this->getWriterIndex() && + // Stay in lagged replica mode during the load balancer instance lifetime + !$this->laggedReplicaMode + ) { try { - // See if laggedReplicaMode gets set - $conn = $this->getConnection( self::DB_REPLICA, false, $domain ); - $this->reuseConnection( $conn ); + // Calling this method will set "laggedReplicaMode" as needed + $this->getReaderIndex( false, $domain ); } catch ( DBConnectionError $e ) { // Avoid expensive re-connect attempts and failures $this->allReplicasDownMode = true; @@ -1815,21 +1984,33 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @return int + */ + private function getCurrentConnectionCount() { + $count = 0; + foreach ( $this->conns as $connsByServer ) { + foreach ( $connsByServer as $serverConns ) { + $count += count( $serverConns ); + } + } + + return $count; + } + public function getMaxLag( $domain = false ) { - $maxLag = -1; $host = ''; + $maxLag = -1; $maxIndex = 0; - if ( $this->getServerCount() <= 1 ) { - return [ $host, $maxLag, $maxIndex ]; // no replication = no lag - } - - $lagTimes = $this->getLagTimes( $domain ); - foreach ( $lagTimes as $i => $lag ) { - if ( $this->loads[$i] > 0 && $lag > $maxLag ) { - $maxLag = $lag; - $host = $this->servers[$i]['host']; - $maxIndex = $i; + if ( $this->hasReplicaServers() ) { + $lagTimes = $this->getLagTimes( $domain ); + foreach ( $lagTimes as $i => $lag ) { + if ( $this->genericLoads[$i] > 0 && $lag > $maxLag ) { + $maxLag = $lag; + $host = $this->getServerInfoStrict( $i, 'host' ); + $maxIndex = $i; + } } } @@ -1837,7 +2018,7 @@ class LoadBalancer implements ILoadBalancer { } public function getLagTimes( $domain = false ) { - if ( $this->getServerCount() <= 1 ) { + if ( !$this->hasReplicaServers() ) { return [ $this->getWriterIndex() => 0 ]; // no replication = no lag } @@ -1854,15 +2035,32 @@ class LoadBalancer implements ILoadBalancer { return $this->getLoadMonitor()->getLagTimes( $indexesWithLag, $domain ) + $knownLagTimes; } + /** + * Get the lag in seconds for a given connection, or zero if this load + * balancer does not have replication enabled. + * + * This should be used in preference to Database::getLag() in cases where + * replication may not be in use, since there is no way to determine if + * replication is in use at the connection level without running + * potentially restricted queries such as SHOW SLAVE STATUS. Using this + * function instead of Database::getLag() avoids a fatal error in this + * case on many installations. + * + * @param IDatabase $conn + * @return int|bool Returns false on error + * @deprecated Since 1.34 Use IDatabase::getLag() instead + */ public function safeGetLag( IDatabase $conn ) { - if ( $this->getServerCount() <= 1 ) { - return 0; - } else { - return $conn->getLag(); + if ( $conn->getLBInfo( 'is static' ) ) { + return 0; // static dataset + } elseif ( $conn->getLBInfo( 'serverIndex' ) == $this->getWriterIndex() ) { + return 0; // this is the master } + + return $conn->getLag(); } - public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { + public function waitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { $timeout = max( 1, $timeout ?: $this->waitTimeout ); if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) { @@ -1871,11 +2069,13 @@ class LoadBalancer implements ILoadBalancer { if ( !$pos ) { // Get the current master position, opening a connection if needed - $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() ); + $index = $this->getWriterIndex(); + $masterConn = $this->getAnyOpenConnection( $index ); if ( $masterConn ) { $pos = $masterConn->getMasterPos(); } else { - $masterConn = $this->openConnection( $this->getWriterIndex(), self::DOMAIN_ANY ); + $flags = self::CONN_SILENCE_ERRORS; + $masterConn = $this->getConnection( $index, [], self::DOMAIN_ANY, $flags ); if ( !$masterConn ) { throw new DBReplicationWaitError( null, @@ -1888,12 +2088,15 @@ class LoadBalancer implements ILoadBalancer { } if ( $pos instanceof DBMasterPos ) { + $start = microtime( true ); $result = $conn->masterPosWait( $pos, $timeout ); + $seconds = max( microtime( true ) - $start, 0 ); if ( $result == -1 || is_null( $result ) ) { - $msg = __METHOD__ . ': timed out waiting on {host} pos {pos}'; + $msg = __METHOD__ . ': timed out waiting on {host} pos {pos} [{seconds}s]'; $this->replLogger->warning( $msg, [ 'host' => $conn->getServer(), 'pos' => $pos, + 'seconds' => round( $seconds, 6 ), 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); $ok = false; @@ -1915,6 +2118,22 @@ class LoadBalancer implements ILoadBalancer { return $ok; } + /** + * Wait for a replica DB to reach a specified master position + * + * This will connect to the master to get an accurate position if $pos is not given + * + * @param IDatabase $conn Replica DB + * @param DBMasterPos|bool $pos Master position; default: current position + * @param int $timeout Timeout in seconds [optional] + * @return bool Success + * @since 1.28 + * @deprecated Since 1.34 Use waitForMasterPos() instead + */ + public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) { + return $this->waitForMasterPos( $conn, $pos, $timeout ); + } + public function setTransactionListener( $name, callable $callback = null ) { if ( $callback ) { $this->trxRecurringCallbacks[$name] = $callback; @@ -1959,7 +2178,7 @@ class LoadBalancer implements ILoadBalancer { if ( $domainsInUse ) { $domains = implode( ', ', $domainsInUse ); throw new DBUnexpectedError( null, - "Foreign domain connections are still in use ($domains)." ); + "Foreign domain connections are still in use ($domains)" ); } $this->setLocalDomain( new DatabaseDomain( @@ -1997,6 +2216,28 @@ class LoadBalancer implements ILoadBalancer { } } + /** + * @param int $i Server index + * @param string|null $field Server index field [optional] + * @return array|mixed + * @throws InvalidArgumentException + */ + private function getServerInfoStrict( $i, $field = null ) { + if ( !isset( $this->servers[$i] ) || !is_array( $this->servers[$i] ) ) { + throw new InvalidArgumentException( "No server with index '$i'" ); + } + + if ( $field !== null ) { + if ( !array_key_exists( $field, $this->servers[$i] ) ) { + throw new InvalidArgumentException( "No field '$field' in server index '$i'" ); + } + + return $this->servers[$i][$field]; + } + + return $this->servers[$i]; + } + function __destruct() { // Avoid connection leaks for sanity $this->disable(); diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php b/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php index fcddfcf139..4c68833e7e 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php @@ -86,6 +86,10 @@ class LoadBalancerSingle extends LoadBalancer { protected function reallyOpenConnection( array $server, DatabaseDomain $domain ) { return $this->db; } + + public function __destruct() { + // do nothing since the connection was injected + } } /** diff --git a/includes/libs/rdbms/loadmonitor/LoadMonitor.php b/includes/libs/rdbms/loadmonitor/LoadMonitor.php index 180baed753..1666c275c7 100644 --- a/includes/libs/rdbms/loadmonitor/LoadMonitor.php +++ b/includes/libs/rdbms/loadmonitor/LoadMonitor.php @@ -154,12 +154,12 @@ class LoadMonitor implements ILoadMonitor { # Handles with open transactions are avoided since they might be subject # to REPEATABLE-READ snapshots, which could affect the lag estimate query. - $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; + $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT | ILoadBalancer::CONN_SILENCE_ERRORS; $conn = $this->parent->getAnyOpenConnection( $i, $flags ); if ( $conn ) { $close = false; // already open } else { - $conn = $this->parent->openConnection( $i, ILoadBalancer::DOMAIN_ANY, $flags ); + $conn = $this->parent->getConnection( $i, [], ILoadBalancer::DOMAIN_ANY, $flags ); $close = true; // new connection } @@ -181,25 +181,21 @@ class LoadMonitor implements ILoadMonitor { continue; } - if ( $conn->getLBInfo( 'is static' ) ) { - $lagTimes[$i] = 0; - } else { - $lagTimes[$i] = $conn->getLag(); - if ( $lagTimes[$i] === false ) { - $this->replLogger->error( - __METHOD__ . ": host {db_server} is not replicating?", - [ 'db_server' => $host ] - ); - } elseif ( $lagTimes[$i] > $this->lagWarnThreshold ) { - $this->replLogger->warning( - "Server {host} has {lag} seconds of lag (>= {maxlag})", - [ - 'host' => $host, - 'lag' => $lagTimes[$i], - 'maxlag' => $this->lagWarnThreshold - ] - ); - } + $lagTimes[$i] = $conn->getLag(); + if ( $lagTimes[$i] === false ) { + $this->replLogger->error( + __METHOD__ . ": host {db_server} is not replicating?", + [ 'db_server' => $host ] + ); + } elseif ( $lagTimes[$i] > $this->lagWarnThreshold ) { + $this->replLogger->warning( + "Server {host} has {lag} seconds of lag (>= {maxlag})", + [ + 'host' => $host, + 'lag' => $lagTimes[$i], + 'maxlag' => $this->lagWarnThreshold + ] + ); } if ( $close ) { diff --git a/includes/libs/replacers/DoubleReplacer.php b/includes/libs/replacers/DoubleReplacer.php deleted file mode 100644 index 9d05e062a9..0000000000 --- a/includes/libs/replacers/DoubleReplacer.php +++ /dev/null @@ -1,46 +0,0 @@ -from = $from; - $this->to = $to; - $this->index = $index; - } - - /** - * @param array $matches - * @return mixed - */ - public function replace( array $matches ) { - return str_replace( $this->from, $this->to, $matches[$this->index] ); - } -} diff --git a/includes/libs/replacers/HashtableReplacer.php b/includes/libs/replacers/HashtableReplacer.php deleted file mode 100644 index 8247694a0d..0000000000 --- a/includes/libs/replacers/HashtableReplacer.php +++ /dev/null @@ -1,46 +0,0 @@ -table = $table; - $this->index = $index; - } - - /** - * @param array $matches - * @return mixed - */ - public function replace( array $matches ) { - return $this->table[$matches[$this->index]]; - } -} diff --git a/includes/libs/replacers/RegexlikeReplacer.php b/includes/libs/replacers/RegexlikeReplacer.php deleted file mode 100644 index bdc4dc0044..0000000000 --- a/includes/libs/replacers/RegexlikeReplacer.php +++ /dev/null @@ -1,49 +0,0 @@ -r = $r; - } - - /** - * @param array $matches - * @return string - */ - public function replace( array $matches ) { - $pairs = []; - foreach ( $matches as $i => $match ) { - $pairs["\$$i"] = $match; - } - - return strtr( $this->r, $pairs ); - } -} diff --git a/includes/libs/replacers/Replacer.php b/includes/libs/replacers/Replacer.php deleted file mode 100644 index 5425eedd74..0000000000 --- a/includes/libs/replacers/Replacer.php +++ /dev/null @@ -1,41 +0,0 @@ -serviceInstantiators[$name] ); } + /** @inheritDoc */ + public function has( $name ) { + return $this->hasService( $name ); + } + /** * Returns the service instance for $name only if that service has already been instantiated. * This is intended for situations where services get destroyed/cleaned up, so we can @@ -417,6 +424,11 @@ class ServiceContainer implements DestructibleService { return $this->services[$name]; } + /** @inheritDoc */ + public function get( $name ) { + return $this->getService( $name ); + } + /** * @param string $name * diff --git a/includes/libs/services/ServiceDisabledException.php b/includes/libs/services/ServiceDisabledException.php index 86b927bddf..bdca5181f1 100644 --- a/includes/libs/services/ServiceDisabledException.php +++ b/includes/libs/services/ServiceDisabledException.php @@ -1,7 +1,9 @@ buffer; - } - public function hasData() { return !empty( $this->buffer ); } diff --git a/includes/libs/stats/IBufferingStatsdDataFactory.php b/includes/libs/stats/IBufferingStatsdDataFactory.php index 77b4c3528a..1dd70a4d22 100644 --- a/includes/libs/stats/IBufferingStatsdDataFactory.php +++ b/includes/libs/stats/IBufferingStatsdDataFactory.php @@ -1,4 +1,5 @@ entry->getTarget(); // Preload user page for non-autoblocks - if ( substr( $title->getText(), 0, 1 ) !== '#' ) { + if ( substr( $title->getText(), 0, 1 ) !== '#' && $title->isValid() ) { return [ $title->getTalkPage() ]; } return []; diff --git a/includes/logging/DeleteLogFormatter.php b/includes/logging/DeleteLogFormatter.php index 8078e2e02c..048b567c70 100644 --- a/includes/logging/DeleteLogFormatter.php +++ b/includes/logging/DeleteLogFormatter.php @@ -270,8 +270,6 @@ class DeleteLogFormatter extends LogFormatter { } } - $old = $this->parseBitField( $rawParams['6::ofield'] ); - $new = $this->parseBitField( $rawParams['7::nfield'] ); if ( !is_array( $rawParams['5::ids'] ) ) { $rawParams['5::ids'] = explode( ',', $rawParams['5::ids'] ); } @@ -279,8 +277,6 @@ class DeleteLogFormatter extends LogFormatter { $params = [ '::type' => $rawParams['4::type'], ':array:ids' => $rawParams['5::ids'], - ':assoc:old' => [ 'bitmask' => $old ], - ':assoc:new' => [ 'bitmask' => $new ], ]; static $fields = [ @@ -289,9 +285,20 @@ class DeleteLogFormatter extends LogFormatter { Revision::DELETED_USER => 'user', Revision::DELETED_RESTRICTED => 'restricted', ]; - foreach ( $fields as $bit => $key ) { - $params[':assoc:old'][$key] = (bool)( $old & $bit ); - $params[':assoc:new'][$key] = (bool)( $new & $bit ); + + if ( isset( $rawParams['6::ofield'] ) ) { + $old = $this->parseBitField( $rawParams['6::ofield'] ); + $params[':assoc:old'] = [ 'bitmask' => $old ]; + foreach ( $fields as $bit => $key ) { + $params[':assoc:old'][$key] = (bool)( $old & $bit ); + } + } + if ( isset( $rawParams['7::nfield'] ) ) { + $new = $this->parseBitField( $rawParams['7::nfield'] ); + $params[':assoc:new'] = [ 'bitmask' => $new ]; + foreach ( $fields as $bit => $key ) { + $params[':assoc:new'][$key] = (bool)( $new & $bit ); + } } } elseif ( $subtype === 'restore' ) { $rawParams = $entry->getParameters(); diff --git a/includes/logging/LogEventsList.php b/includes/logging/LogEventsList.php index 3fd52af01b..e66bd69cd5 100644 --- a/includes/logging/LogEventsList.php +++ b/includes/logging/LogEventsList.php @@ -531,7 +531,7 @@ class LogEventsList extends ContextSource { /** * Determine if the current user is allowed to view a particular - * field of this log row, if it's marked as deleted. + * field of this log row, if it's marked as deleted and/or restricted log type. * * @param stdClass $row * @param int $field @@ -539,7 +539,8 @@ class LogEventsList extends ContextSource { * @return bool */ public static function userCan( $row, $field, User $user = null ) { - return self::userCanBitfield( $row->log_deleted, $field, $user ); + return self::userCanBitfield( $row->log_deleted, $field, $user ) && + self::userCanViewLogType( $row->log_type, $user ); } /** @@ -569,6 +570,26 @@ class LogEventsList extends ContextSource { return true; } + /** + * Determine if the current user is allowed to view a particular + * field of this log row, if it's marked as restricted log type. + * + * @param stdClass $type + * @param User|null $user User to check, or null to use $wgUser + * @return bool + */ + public static function userCanViewLogType( $type, User $user = null ) { + if ( $user === null ) { + global $wgUser; + $user = $wgUser; + } + $logRestrictions = MediaWikiServices::getInstance()->getMainConfig()->get( 'LogRestrictions' ); + if ( isset( $logRestrictions[$type] ) && !$user->isAllowed( $logRestrictions[$type] ) ) { + return false; + } + return true; + } + /** * @param stdClass $row * @param int $field One of DELETED_* bitfield constants diff --git a/includes/logging/LogFormatter.php b/includes/logging/LogFormatter.php index 3e942ae08d..e8dd8982b5 100644 --- a/includes/logging/LogFormatter.php +++ b/includes/logging/LogFormatter.php @@ -613,9 +613,13 @@ class LogFormatter { $this->setShowUserToolLinks( false ); $user = User::newFromName( $value ); - $value = Message::rawParam( $this->makeUserLink( $user ) ); - $this->setShowUserToolLinks( $saveLinkFlood ); + if ( !$user ) { + $value = $this->msg( 'empty-username' )->text(); + } else { + $value = Message::rawParam( $this->makeUserLink( $user ) ); + $this->setShowUserToolLinks( $saveLinkFlood ); + } break; case 'title': $title = Title::newFromText( $value ); diff --git a/includes/logging/LogPage.php b/includes/logging/LogPage.php index 1fc56bb527..fe9e26f7d3 100644 --- a/includes/logging/LogPage.php +++ b/includes/logging/LogPage.php @@ -403,7 +403,7 @@ class LogPage { } $dbw = wfGetDB( DB_MASTER ); - $dbw->insert( 'log_search', $data, __METHOD__, 'IGNORE' ); + $dbw->insert( 'log_search', $data, __METHOD__, [ 'IGNORE' ] ); return true; } diff --git a/includes/logging/ManualLogEntry.php b/includes/logging/ManualLogEntry.php index 90c0a72292..1d0bbfd6fa 100644 --- a/includes/logging/ManualLogEntry.php +++ b/includes/logging/ManualLogEntry.php @@ -335,7 +335,7 @@ class ManualLogEntry extends LogEntryBase implements Taggable { } } if ( count( $rows ) ) { - $dbw->insert( 'log_search', $rows, __METHOD__, 'IGNORE' ); + $dbw->insert( 'log_search', $rows, __METHOD__, [ 'IGNORE' ] ); } return $this->id; diff --git a/includes/mail/EmailNotification.php b/includes/mail/EmailNotification.php index 0b77651bda..7361032937 100644 --- a/includes/mail/EmailNotification.php +++ b/includes/mail/EmailNotification.php @@ -407,7 +407,7 @@ class EmailNotification { * @param User $user * @param string $source */ - function compose( $user, $source ) { + private function compose( $user, $source ) { global $wgEnotifImpersonal; if ( !$this->composed_common ) { @@ -424,7 +424,7 @@ class EmailNotification { /** * Send any queued mails */ - function sendMails() { + private function sendMails() { global $wgEnotifImpersonal; if ( $wgEnotifImpersonal ) { $this->sendImpersonal( $this->mailTargets ); @@ -440,9 +440,8 @@ class EmailNotification { * @param User $watchingUser * @param string $source * @return Status - * @private */ - function sendPersonalised( $watchingUser, $source ) { + private function sendPersonalised( $watchingUser, $source ) { global $wgEnotifUseRealName; // From the PHP manual: // Note: The to parameter cannot be an address in the form of @@ -481,7 +480,7 @@ class EmailNotification { * @param MailAddress[] $addresses * @return Status|null */ - function sendImpersonal( $addresses ) { + private function sendImpersonal( $addresses ) { if ( empty( $addresses ) ) { return null; } diff --git a/includes/mail/MailAddress.php b/includes/mail/MailAddress.php index 63a114d2a9..1a5d08ac2a 100644 --- a/includes/mail/MailAddress.php +++ b/includes/mail/MailAddress.php @@ -71,7 +71,7 @@ class MailAddress { * Return formatted and quoted address to insert into SMTP headers * @return string */ - function toString() { + public function toString() { if ( !$this->address ) { return ''; } @@ -94,7 +94,7 @@ class MailAddress { return "$quoted <{$this->address}>"; } - function __toString() { + public function __toString() { return $this->toString(); } } diff --git a/includes/mail/UserMailer.php b/includes/mail/UserMailer.php index 5d7030bb62..47fa16f87f 100644 --- a/includes/mail/UserMailer.php +++ b/includes/mail/UserMailer.php @@ -64,7 +64,7 @@ class UserMailer { * * @return string */ - static function arrayToHeaderString( $headers, $endl = PHP_EOL ) { + private static function arrayToHeaderString( $headers, $endl = PHP_EOL ) { $strings = []; foreach ( $headers as $name => $value ) { // Prevent header injection by stripping newlines from value @@ -79,7 +79,7 @@ class UserMailer { * * @return string */ - static function makeMsgId() { + private static function makeMsgId() { global $wgSMTP, $wgServer; $domainId = WikiMap::getCurrentWikiDbDomain()->getId(); @@ -465,7 +465,7 @@ class UserMailer { * @param int $code Error number * @param string $string Error message */ - static function errorHandler( $code, $string ) { + private static function errorHandler( $code, $string ) { self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string ); } diff --git a/includes/media/BitmapHandler.php b/includes/media/BitmapHandler.php index 2d1c3b2b51..e07b16610d 100644 --- a/includes/media/BitmapHandler.php +++ b/includes/media/BitmapHandler.php @@ -110,7 +110,7 @@ class BitmapHandler extends TransformationalImageHandler { * Get ImageMagick subsampling factors for the target JPEG pixel format. * * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' - * @return array of string keys + * @return string[] List of sampling factors */ protected function imageMagickSubsampling( $pixelFormat ) { switch ( $pixelFormat ) { diff --git a/includes/media/BmpHandler.php b/includes/media/BmpHandler.php index 09cbdac23f..9a9c0a6f19 100644 --- a/includes/media/BmpHandler.php +++ b/includes/media/BmpHandler.php @@ -39,12 +39,12 @@ class BmpHandler extends BitmapHandler { /** * Render files as PNG * - * @param string $text + * @param string $ext * @param string $mime * @param array|null $params * @return array */ - public function getThumbType( $text, $mime, $params = null ) { + public function getThumbType( $ext, $mime, $params = null ) { return [ 'png', 'image/png' ]; } diff --git a/includes/media/DjVuImage.php b/includes/media/DjVuImage.php index fde43f404b..13a39edcf2 100644 --- a/includes/media/DjVuImage.php +++ b/includes/media/DjVuImage.php @@ -111,7 +111,7 @@ class DjVuImage { $this->dumpForm( $file, $chunkLength, $indent + 1 ); } else { fseek( $file, $chunkLength, SEEK_CUR ); - if ( ( $chunkLength & 1 ) == 1 ) { + if ( $chunkLength & 1 ) { // Padding byte between chunks fseek( $file, 1, SEEK_CUR ); } @@ -169,7 +169,7 @@ class DjVuImage { private function skipChunk( $file, $chunkLength ) { fseek( $file, $chunkLength, SEEK_CUR ); - if ( ( $chunkLength & 0x01 ) == 1 && !feof( $file ) ) { + if ( ( $chunkLength & 1 ) && !feof( $file ) ) { // padding byte fseek( $file, 1, SEEK_CUR ); } diff --git a/includes/media/Exif.php b/includes/media/Exif.php index 0fde38636b..6dfa8d32e8 100644 --- a/includes/media/Exif.php +++ b/includes/media/Exif.php @@ -548,6 +548,7 @@ class Exif { /**#@+ * @return array */ + /** * Get $this->mRawExifData * @return array diff --git a/includes/media/FormatMetadata.php b/includes/media/FormatMetadata.php index 345b3cb60f..333c610375 100644 --- a/includes/media/FormatMetadata.php +++ b/includes/media/FormatMetadata.php @@ -1181,7 +1181,7 @@ class FormatMetadata extends ContextSource { $langName = Language::fetchLanguageName( $lowLang ); if ( $langName === '' ) { // try just the base language name. (aka en-US -> en ). - list( $langPrefix ) = explode( '-', $lowLang, 2 ); + $langPrefix = explode( '-', $lowLang, 2 )[0]; $langName = Language::fetchLanguageName( $langPrefix ); if ( $langName === '' ) { // give up. diff --git a/includes/media/MediaHandler.php b/includes/media/MediaHandler.php index 1a96c1da43..7dd04918e6 100644 --- a/includes/media/MediaHandler.php +++ b/includes/media/MediaHandler.php @@ -193,7 +193,7 @@ abstract class MediaHandler { * performance problems. * @param File $image * @param string $metadata The metadata in serialized form - * @return bool + * @return bool|int */ public function isMetadataValid( $image, $metadata ) { return self::METADATA_GOOD; diff --git a/includes/media/TransformationalImageHandler.php b/includes/media/TransformationalImageHandler.php index dbeca0be38..95053cfdbd 100644 --- a/includes/media/TransformationalImageHandler.php +++ b/includes/media/TransformationalImageHandler.php @@ -231,7 +231,7 @@ abstract class TransformationalImageHandler extends ImageHandler { } // $scaler will return a MediaTransformError on failure, or false on success. - // If the scaler is succesful, it will have created a thumbnail at the destination + // If the scaler is successful, it will have created a thumbnail at the destination // path. if ( is_array( $scaler ) && is_callable( $scaler ) ) { // Allow subclasses to specify their own rendering methods. diff --git a/includes/media/XCFHandler.php b/includes/media/XCFHandler.php index e47cc371aa..e2cc1b2c8b 100644 --- a/includes/media/XCFHandler.php +++ b/includes/media/XCFHandler.php @@ -186,7 +186,7 @@ class XCFHandler extends BitmapHandler { * * @param File $file The file object for the file in question * @param string $metadata Serialized metadata - * @return bool One of the self::METADATA_(BAD|GOOD|COMPATIBLE) constants + * @return bool|int One of the self::METADATA_(BAD|GOOD|COMPATIBLE) constants */ public function isMetadataValid( $file, $metadata ) { if ( !$metadata ) { diff --git a/includes/objectcache/ObjectCache.php b/includes/objectcache/ObjectCache.php index bc87c70b0f..e9853b176e 100644 --- a/includes/objectcache/ObjectCache.php +++ b/includes/objectcache/ObjectCache.php @@ -382,6 +382,7 @@ class ObjectCache { * @deprecated Since 1.28 Use MediaWikiServices::getInstance()->getMainObjectStash() */ public static function getMainStashInstance() { + wfDeprecated( __METHOD__, '1.28' ); return MediaWikiServices::getInstance()->getMainObjectStash(); } @@ -400,10 +401,10 @@ class ObjectCache { * @return int|string Index to cache in $wgObjectCaches */ public static function detectLocalServerCache() { - if ( function_exists( 'apc_fetch' ) ) { - return 'apc'; - } elseif ( function_exists( 'apcu_fetch' ) ) { + if ( function_exists( 'apcu_fetch' ) ) { return 'apcu'; + } elseif ( function_exists( 'apc_fetch' ) ) { + return 'apc'; } elseif ( function_exists( 'wincache_ucache_get' ) ) { return 'wincache'; } diff --git a/includes/objectcache/SqlBagOStuff.php b/includes/objectcache/SqlBagOStuff.php index 088f94e37c..39d0353d45 100644 --- a/includes/objectcache/SqlBagOStuff.php +++ b/includes/objectcache/SqlBagOStuff.php @@ -250,7 +250,7 @@ class SqlBagOStuff extends BagOStuff { return false; } - public function getMulti( array $keys, $flags = 0 ) { + protected function doGetMulti( array $keys, $flags = 0 ) { $values = []; $blobs = $this->fetchBlobMulti( $keys ); @@ -261,7 +261,7 @@ class SqlBagOStuff extends BagOStuff { return $values; } - public function fetchBlobMulti( array $keys, $flags = 0 ) { + protected function fetchBlobMulti( array $keys, $flags = 0 ) { $values = []; // array of (key => value) $keysByTable = []; @@ -338,6 +338,7 @@ class SqlBagOStuff extends BagOStuff { $result = true; $exptime = (int)$expiry; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); foreach ( $keysByTable as $serverIndex => $serverKeys ) { $db = null; @@ -391,8 +392,8 @@ class SqlBagOStuff extends BagOStuff { return $result; } - public function set( $key, $value, $exptime = 0, $flags = 0 ) { - $ok = $this->setMulti( [ $key => $value ], $exptime ); + protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) { + $ok = $this->insertMulti( [ $key => $value ], $exptime, $flags, true ); return $ok; } @@ -406,6 +407,7 @@ class SqlBagOStuff extends BagOStuff { protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); @@ -446,6 +448,10 @@ class SqlBagOStuff extends BagOStuff { } public function deleteMulti( array $keys, $flags = 0 ) { + return $this->purgeMulti( $keys, $flags ); + } + + public function purgeMulti( array $keys, $flags = 0 ) { $keysByTable = []; foreach ( $keys as $key ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); @@ -453,6 +459,7 @@ class SqlBagOStuff extends BagOStuff { } $result = true; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); foreach ( $keysByTable as $serverIndex => $serverKeys ) { $db = null; @@ -482,8 +489,8 @@ class SqlBagOStuff extends BagOStuff { return $result; } - public function delete( $key, $flags = 0 ) { - $ok = $this->deleteMulti( [ $key ], $flags ); + protected function doDelete( $key, $flags = 0 ) { + $ok = $this->purgeMulti( [ $key ], $flags ); return $ok; } @@ -491,6 +498,7 @@ class SqlBagOStuff extends BagOStuff { public function incr( $key, $step = 1 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); @@ -522,7 +530,7 @@ class SqlBagOStuff extends BagOStuff { 'exptime' => $row->exptime ], __METHOD__, - 'IGNORE' + [ 'IGNORE' ] ); if ( $db->affectedRows() == 0 ) { @@ -549,6 +557,7 @@ class SqlBagOStuff extends BagOStuff { public function changeTTL( $key, $exptime = 0, $flags = 0 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); @@ -631,6 +640,7 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) { + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { $db = null; @@ -709,6 +719,7 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ public function deleteAll() { + /** @noinspection PhpUnusedLocalVariableInspection */ $silenceScope = $this->silenceTransactionProfiler(); for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { $db = null; @@ -725,15 +736,77 @@ class SqlBagOStuff extends BagOStuff { return true; } + public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) { + // Avoid deadlocks and allow lock reentry if specified + if ( isset( $this->locks[$key] ) ) { + if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) { + ++$this->locks[$key]['depth']; + return true; + } else { + return false; + } + } + + list( $serverIndex ) = $this->getTableByKey( $key ); + try { + $db = $this->getDB( $serverIndex ); + $ok = $db->lock( $key, __METHOD__, $timeout ); + if ( $ok ) { + $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ]; + } + + $this->logger->warning( + __METHOD__ . " failed due to timeout for {key}.", + [ 'key' => $key, 'timeout' => $timeout ] + ); + + return $ok; + } catch ( DBError $e ) { + $this->handleWriteError( $e, $db, $serverIndex ); + $ok = false; + } + + return $ok; + } + + public function unlock( $key ) { + if ( !isset( $this->locks[$key] ) ) { + return false; + } + + if ( --$this->locks[$key]['depth'] <= 0 ) { + unset( $this->locks[$key] ); + + list( $serverIndex ) = $this->getTableByKey( $key ); + try { + $db = $this->getDB( $serverIndex ); + $ok = $db->unlock( $key, __METHOD__ ); + if ( !$ok ) { + $this->logger->warning( + __METHOD__ . ' failed to release lock for {key}.', + [ 'key' => $key ] + ); + } + } catch ( DBError $e ) { + $this->handleWriteError( $e, $db, $serverIndex ); + $ok = false; + } + } else { + $ok = false; + } + + return $ok; + } + /** * Serialize an object and, if possible, compress the representation. * On typical message and page data, this can provide a 3X decrease * in storage requirements. * - * @param mixed &$data + * @param mixed $data * @return string */ - protected function serialize( &$data ) { + protected function serialize( $data ) { $serial = serialize( $data ); if ( function_exists( 'gzdeflate' ) ) { @@ -786,8 +859,8 @@ class SqlBagOStuff extends BagOStuff { * @param int $serverIndex * @throws Exception */ - protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) { - if ( !$db ) { + protected function handleWriteError( DBError $exception, $db, $serverIndex ) { + if ( !( $db instanceof IDatabase ) ) { $this->markServerDown( $exception, $serverIndex ); } diff --git a/includes/page/Article.php b/includes/page/Article.php index b20c83e269..aa38d1f787 100644 --- a/includes/page/Article.php +++ b/includes/page/Article.php @@ -19,6 +19,7 @@ * * @file */ +use MediaWiki\Block\DatabaseBlock; use MediaWiki\MediaWikiServices; use MediaWiki\Revision\MutableRevisionRecord; use MediaWiki\Revision\RevisionRecord; @@ -959,7 +960,7 @@ class Article implements Page { } else { $specificTarget = $titleText; } - if ( Block::newFromTarget( $specificTarget, $vagueTarget ) instanceof Block ) { + if ( DatabaseBlock::newFromTarget( $specificTarget, $vagueTarget ) instanceof DatabaseBlock ) { return [ 'index' => 'noindex', 'follow' => 'nofollow' @@ -1361,14 +1362,14 @@ class Article implements Page { $rootPart = explode( '/', $title->getText() )[0]; $user = User::newFromName( $rootPart, false /* allow IP users */ ); $ip = User::isIP( $rootPart ); - $block = Block::newFromTarget( $user, $user ); + $block = DatabaseBlock::newFromTarget( $user, $user ); if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist $outputPage->wrapWikiMsg( "
\n\$1\n
", [ 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) ] ); } elseif ( !is_null( $block ) && - $block->getType() != Block::TYPE_AUTO && + $block->getType() != DatabaseBlock::TYPE_AUTO && ( $block->isSitewide() || $user->isBlockedFrom( $title ) ) ) { // Show log extract if the user is sitewide blocked or is partially diff --git a/includes/page/ImagePage.php b/includes/page/ImagePage.php index 86c59add09..16b83d1f50 100644 --- a/includes/page/ImagePage.php +++ b/includes/page/ImagePage.php @@ -29,7 +29,7 @@ use Wikimedia\Rdbms\ResultWrapper; * @ingroup Media */ class ImagePage extends Article { - /** @var File */ + /** @var File|false */ private $displayImg; /** @var FileRepo */ @@ -75,9 +75,10 @@ class ImagePage extends Article { Hooks::run( 'ImagePageFindFile', [ $this, &$img, &$this->displayImg ] ); if ( !$img ) { // not set by hook? - $img = wfFindFile( $this->getTitle() ); + $services = MediaWikiServices::getInstance(); + $img = $services->getRepoGroup()->findFile( $this->getTitle() ); if ( !$img ) { - $img = wfLocalFile( $this->getTitle() ); + $img = $services->getRepoGroup()->getLocalRepo()->newFile( $this->getTitle() ); } } $this->mPage->setFile( $img ); @@ -800,7 +801,7 @@ EOT } /** - * @param string $target + * @param string|string[] $target * @param int $limit * @return ResultWrapper */ @@ -934,7 +935,7 @@ EOT ) . "\n" ); - }; + } $out->addHTML( Html::closeElement( 'ul' ) . "\n" ); $res->free(); diff --git a/includes/page/PageArchive.php b/includes/page/PageArchive.php index a314f3a9ea..d69a433d9c 100644 --- a/includes/page/PageArchive.php +++ b/includes/page/PageArchive.php @@ -406,8 +406,8 @@ class PageArchive { * @param User|null $user User performing the action, or null to use $wgUser * @param string|string[]|null $tags Change tags to add to log entry * ($user should be able to add the specified tags before this is called) - * @return array|bool array(number of file revisions restored, number of image revisions - * restored, log message) on success, false on failure. + * @return array|bool [ number of file revisions restored, number of image revisions + * restored, log message ] on success, false on failure. */ public function undelete( $timestamps, $comment = '', $fileVersions = [], $unsuppress = false, User $user = null, $tags = null @@ -420,7 +420,9 @@ class PageArchive { $restoreFiles = $restoreAll || !empty( $fileVersions ); if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) { - $img = wfLocalFile( $this->title ); + /** @var LocalFile $img */ + $img = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo() + ->newFile( $this->title ); $img->load( File::READ_LATEST ); $this->fileStatus = $img->restore( $fileVersions, $unsuppress ); if ( !$this->fileStatus->isOK() ) { diff --git a/includes/page/WikiFilePage.php b/includes/page/WikiFilePage.php index c457a34fe1..acd506ba79 100644 --- a/includes/page/WikiFilePage.php +++ b/includes/page/WikiFilePage.php @@ -20,6 +20,7 @@ * @file */ +use MediaWiki\MediaWikiServices; use Wikimedia\Rdbms\FakeResultWrapper; /** @@ -28,13 +29,13 @@ use Wikimedia\Rdbms\FakeResultWrapper; * @ingroup Media */ class WikiFilePage extends WikiPage { - /** @var File */ + /** @var File|false */ protected $mFile = false; - /** @var LocalRepo */ + /** @var LocalRepo|null */ protected $mRepo = null; /** @var bool */ protected $mFileLoaded = false; - /** @var array */ + /** @var array|null */ protected $mDupes = null; public function __construct( $title ) { @@ -55,14 +56,16 @@ class WikiFilePage extends WikiPage { * @return bool */ protected function loadFile() { + $services = MediaWikiServices::getInstance(); if ( $this->mFileLoaded ) { return true; } $this->mFileLoaded = true; - $this->mFile = wfFindFile( $this->mTitle ); + $this->mFile = $services->getRepoGroup()->findFile( $this->mTitle ); if ( !$this->mFile ) { - $this->mFile = wfLocalFile( $this->mTitle ); // always a File + $this->mFile = $services->getRepoGroup()->getLocalRepo() + ->newFile( $this->mTitle ); // always a File } $this->mRepo = $this->mFile->getRepo(); return true; @@ -149,7 +152,7 @@ class WikiFilePage extends WikiPage { $size = $this->mFile->getSize(); /** - * @var $file File + * @var File $file */ foreach ( $dupes as $index => $file ) { $key = $file->getRepoName() . ':' . $file->getName(); diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 8f39650cbd..9e80cf4936 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -1342,7 +1342,7 @@ class WikiPage implements Page, IDBAccessObject { 'page_len' => 0, // Fill this in shortly... ] + $pageIdForInsert, __METHOD__, - 'IGNORE' + [ 'IGNORE' ] ); if ( $dbw->affectedRows() > 0 ) { @@ -1697,6 +1697,7 @@ class WikiPage implements Page, IDBAccessObject { MediaWikiServices::getInstance()->getDBLoadBalancerFactory() ); + $derivedDataUpdater->setLogger( LoggerFactory::getInstance( 'SaveParse' ) ); $derivedDataUpdater->setRcWatchCategoryMembership( $wgRCWatchCategoryMembership ); $derivedDataUpdater->setArticleCountMethod( $wgArticleCountMethod ); @@ -1904,7 +1905,11 @@ class WikiPage implements Page, IDBAccessObject { // TODO: this logic should not be in the storage layer, it's here for compatibility // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same // place the 'bot' right is handled, which is currently in EditPage::attemptSave. - if ( $needsPatrol && $this->getTitle()->userCan( 'autopatrol', $user ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $needsPatrol && $permissionManager->userCan( + 'autopatrol', $user, $this->getTitle() + ) ) { $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); } @@ -1962,9 +1967,7 @@ class WikiPage implements Page, IDBAccessObject { * @deprecated since 1.32, use getDerivedDataUpdater instead. * * @param Content $content - * @param Revision|RevisionRecord|int|null $revision Revision object. - * For backwards compatibility, a revision ID is also accepted, - * but this is deprecated. + * @param Revision|RevisionRecord|null $revision Revision object. * Used with vary-revision or vary-revision-id. * @param User|null $user * @param string|null $serialFormat IGNORED @@ -1987,19 +1990,13 @@ class WikiPage implements Page, IDBAccessObject { $user = $wgUser; } - if ( !is_object( $revision ) ) { - $revid = $revision; - // This code path is deprecated, and nothing is known to - // use it, so performance here shouldn't be a worry. - if ( $revid !== null ) { - wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' ); - $store = $this->getRevisionStore(); - $revision = $store->getRevisionById( $revid, Revision::READ_LATEST ); - } else { - $revision = null; + if ( $revision !== null ) { + if ( $revision instanceof Revision ) { + $revision = $revision->getRevisionRecord(); + } elseif ( !( $revision instanceof RevisionRecord ) ) { + throw new InvalidArgumentException( + __METHOD__ . ': invalid $revision argument type ' . gettype( $revision ) ); } - } elseif ( $revision instanceof Revision ) { - $revision = $revision->getRevisionRecord(); } $slots = RevisionSlotsUpdate::newFromContent( [ SlotRecord::MAIN => $content ] ); @@ -2114,8 +2111,6 @@ class WikiPage implements Page, IDBAccessObject { * - defer: one of the DeferredUpdates constants, or false to run immediately (default: false). * Note that even when this is set to false, some updates might still get deferred (as * some update might directly add child updates to DeferredUpdates). - * - transactionTicket: a transaction ticket from LBFactory::getEmptyTransactionTicket(), - * only when defer is false (default: null) * @since 1.32 */ public function doSecondaryDataUpdates( array $options = [] ) { @@ -3083,7 +3078,7 @@ class WikiPage implements Page, IDBAccessObject { * (with ChangeTags::canAddTagsAccompanyingChange) * * @return array Array of errors, each error formatted as - * array(messagekey, param1, param2, ...). + * [ messagekey, param1, param2, ... ]. * On success, the array is empty. This array can also be passed to * OutputPage::showPermissionsErrorPage(). */ @@ -3277,7 +3272,11 @@ class WikiPage implements Page, IDBAccessObject { // TODO: this logic should not be in the storage layer, it's here for compatibility // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same // place the 'bot' right is handled, which is currently in EditPage::attemptSave. - if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $guser ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $wgUseRCPatrol && $permissionManager->userCan( + 'autopatrol', $guser, $this->getTitle() + ) ) { $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); } diff --git a/includes/pager/IndexPager.php b/includes/pager/IndexPager.php index 64dbb228a4..04021cc038 100644 --- a/includes/pager/IndexPager.php +++ b/includes/pager/IndexPager.php @@ -23,6 +23,8 @@ use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; +use MediaWiki\Linker\LinkTarget; +use MediaWiki\Navigation\PrevNextNavigationRenderer; /** * IndexPager is an efficient pager which uses a (roughly unique) index in the @@ -784,4 +786,22 @@ abstract class IndexPager extends ContextSource implements Pager { protected function getDefaultDirections() { return self::DIR_ASCENDING; } + + /** + * Generate (prev x| next x) (20|50|100...) type links for paging + * + * @param LinkTarget $title + * @param int $offset + * @param int $limit + * @param array $query Optional URL query parameter string + * @param bool $atend Optional param for specified if this is the last page + * @return string + */ + protected function buildPrevNextNavigation( LinkTarget $title, $offset, $limit, + array $query = [], $atend = false + ) { + $prevNext = new PrevNextNavigationRenderer( $this ); + + return $prevNext->buildPrevNextNavigation( $title, $offset, $limit, $query, $atend ); + } } diff --git a/includes/pager/Pager.php b/includes/pager/Pager.php index edec490c73..9cfbfbf332 100644 --- a/includes/pager/Pager.php +++ b/includes/pager/Pager.php @@ -31,5 +31,6 @@ */ interface Pager { function getNavigationBar(); + function getBody(); } diff --git a/includes/parser/CoreParserFunctions.php b/includes/parser/CoreParserFunctions.php index 7ce96bebaf..7fece00398 100644 --- a/includes/parser/CoreParserFunctions.php +++ b/includes/parser/CoreParserFunctions.php @@ -514,6 +514,7 @@ class CoreParserFunctions { public static function numberofusers( $parser, $raw = null ) { return self::formatRaw( SiteStats::users(), $raw, $parser->getFunctionLang() ); } + public static function numberofactiveusers( $parser, $raw = null ) { return self::formatRaw( SiteStats::activeUsers(), $raw, $parser->getFunctionLang() ); } @@ -545,6 +546,7 @@ class CoreParserFunctions { $parser->getFunctionLang() ); } + public static function numberingroup( $parser, $name = '', $raw = null ) { return self::formatRaw( SiteStats::numberingroup( strtolower( $name ) ), @@ -569,6 +571,7 @@ class CoreParserFunctions { } return str_replace( '_', ' ', $t->getNsText() ); } + public static function namespacee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -576,6 +579,7 @@ class CoreParserFunctions { } return wfUrlencode( $t->getNsText() ); } + public static function namespacenumber( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -583,6 +587,7 @@ class CoreParserFunctions { } return $t->getNamespace(); } + public static function talkspace( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -590,6 +595,7 @@ class CoreParserFunctions { } return str_replace( '_', ' ', $t->getTalkNsText() ); } + public static function talkspacee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -597,6 +603,7 @@ class CoreParserFunctions { } return wfUrlencode( $t->getTalkNsText() ); } + public static function subjectspace( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -604,6 +611,7 @@ class CoreParserFunctions { } return str_replace( '_', ' ', $t->getSubjectNsText() ); } + public static function subjectspacee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -626,6 +634,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getText() ); } + public static function pagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -633,6 +642,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getPartialURL() ); } + public static function fullpagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -640,6 +650,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getPrefixedText() ); } + public static function fullpagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -647,6 +658,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getPrefixedURL() ); } + public static function subpagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -654,6 +666,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getSubpageText() ); } + public static function subpagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -661,6 +674,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getSubpageUrlForm() ); } + public static function rootpagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -668,6 +682,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getRootText() ); } + public static function rootpagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -675,6 +690,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( wfUrlencode( str_replace( ' ', '_', $t->getRootText() ) ) ); } + public static function basepagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -682,6 +698,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getBaseText() ); } + public static function basepagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -689,6 +706,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( wfUrlencode( str_replace( ' ', '_', $t->getBaseText() ) ) ); } + public static function talkpagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -696,6 +714,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getTalkPage()->getPrefixedText() ); } + public static function talkpagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) || !$t->canHaveTalkPage() ) { @@ -703,6 +722,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getTalkPage()->getPrefixedURL() ); } + public static function subjectpagename( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -710,6 +730,7 @@ class CoreParserFunctions { } return wfEscapeWikiText( $t->getSubjectPage()->getPrefixedText() ); } + public static function subjectpagenamee( $parser, $title = null ) { $t = Title::newFromText( $title ); if ( is_null( $t ) ) { @@ -1009,7 +1030,7 @@ class CoreParserFunctions { * @return array|string */ public static function filepath( $parser, $name = '', $argA = '', $argB = '' ) { - $file = wfFindFile( $name ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $name ); if ( $argA == 'nowiki' ) { // {{filepath: | option [| size] }} diff --git a/includes/parser/PPCustomFrame_DOM.php b/includes/parser/PPCustomFrame_DOM.php index 70663a0dea..d274558afc 100644 --- a/includes/parser/PPCustomFrame_DOM.php +++ b/includes/parser/PPCustomFrame_DOM.php @@ -21,6 +21,7 @@ /** * Expansion frame with custom arguments + * @deprecated since 1.34, use PPCustomFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/PPFrame_DOM.php b/includes/parser/PPFrame_DOM.php index a7fea0028a..03ee6d9639 100644 --- a/includes/parser/PPFrame_DOM.php +++ b/includes/parser/PPFrame_DOM.php @@ -21,6 +21,7 @@ /** * An expansion frame, used as a context to expand the result of preprocessToObj() + * @deprecated since 1.34, use PPFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/PPNode_DOM.php b/includes/parser/PPNode_DOM.php index 8a435bab67..26a47911f3 100644 --- a/includes/parser/PPNode_DOM.php +++ b/includes/parser/PPNode_DOM.php @@ -20,6 +20,7 @@ */ /** + * @deprecated since 1.34, use PPNode_Hash_{Tree,Text,Array,Attr} * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/PPTemplateFrame_DOM.php b/includes/parser/PPTemplateFrame_DOM.php index 52cb9cb0f0..b4c874371d 100644 --- a/includes/parser/PPTemplateFrame_DOM.php +++ b/includes/parser/PPTemplateFrame_DOM.php @@ -21,6 +21,7 @@ /** * Expansion frame with template arguments + * @deprecated since 1.34, use PPTemplateFrame_Hash * @ingroup Parser */ // phpcs:ignore Squiz.Classes.ValidClassName.NotCamelCaps diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index b4caff200d..4808cafe90 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -20,6 +20,7 @@ * @file * @ingroup Parser */ +use MediaWiki\Config\ServiceOptions; use MediaWiki\Linker\LinkRenderer; use MediaWiki\Linker\LinkRendererFactory; use MediaWiki\Linker\LinkTarget; @@ -169,8 +170,15 @@ class Parser { * @var MagicWordArray */ public $mSubstWords; + + /** + * @deprecated since 1.34, there should be no need to use this + * @var array + */ + public $mConf; + # Initialised in constructor - public $mConf, $mExtLinkBracketedRegex, $mUrlProtocols; + public $mExtLinkBracketedRegex, $mUrlProtocols; # Initialized in getPreprocessor() /** @var Preprocessor */ @@ -269,8 +277,14 @@ class Parser { /** @var SpecialPageFactory */ private $specialPageFactory; - /** @var Config */ - private $siteConfig; + /** + * This is called $svcOptions instead of $options like elsewhere to avoid confusion with + * $mOptions, which is public and widely used, and also with the local variable $options used + * for ParserOptions throughout this file. + * + * @var ServiceOptions + */ + private $svcOptions; /** @var LinkRendererFactory */ private $linkRendererFactory; @@ -279,45 +293,84 @@ class Parser { private $nsInfo; /** - * @param array $parserConf See $wgParserConf documentation + * TODO Make this a const when HHVM support is dropped (T192166) + * + * @var array + * @since 1.33 + */ + public static $constructorOptions = [ + // See $wgParserConf documentation + 'class', + 'preprocessorClass', + // See documentation for the corresponding config options + 'ArticlePath', + 'EnableScaryTranscluding', + 'ExtraInterlanguageLinkPrefixes', + 'FragmentMode', + 'LanguageCode', + 'MaxSigChars', + 'MaxTocLevel', + 'MiserMode', + 'ScriptPath', + 'Server', + 'ServerName', + 'ShowHostnames', + 'Sitename', + 'StylePath', + 'TranscludeCacheExpiry', + ]; + + /** + * Constructing parsers directly is deprecated! Use a ParserFactory. + * + * @param ServiceOptions|null $svcOptions * @param MagicWordFactory|null $magicWordFactory * @param Language|null $contLang Content language * @param ParserFactory|null $factory * @param string|null $urlProtocols As returned from wfUrlProtocols() * @param SpecialPageFactory|null $spFactory - * @param Config|null $siteConfig * @param LinkRendererFactory|null $linkRendererFactory * @param NamespaceInfo|null $nsInfo */ public function __construct( - array $parserConf = [], MagicWordFactory $magicWordFactory = null, + $svcOptions = null, MagicWordFactory $magicWordFactory = null, Language $contLang = null, ParserFactory $factory = null, $urlProtocols = null, - SpecialPageFactory $spFactory = null, Config $siteConfig = null, - LinkRendererFactory $linkRendererFactory = null, - NamespaceInfo $nsInfo = null + SpecialPageFactory $spFactory = null, $linkRendererFactory = null, $nsInfo = null ) { - $this->mConf = $parserConf; + $services = MediaWikiServices::getInstance(); + if ( !$svcOptions || is_array( $svcOptions ) ) { + // Pre-1.34 calling convention is the first parameter is just ParserConf, the seventh is + // Config, and the eighth is LinkRendererFactory. + $this->mConf = (array)$svcOptions; + if ( empty( $this->mConf['class'] ) ) { + $this->mConf['class'] = self::class; + } + if ( empty( $this->mConf['preprocessorClass'] ) ) { + $this->mConf['preprocessorClass'] = self::getDefaultPreprocessorClass(); + } + $this->svcOptions = new ServiceOptions( self::$constructorOptions, + $this->mConf, + func_num_args() > 6 ? func_get_arg( 6 ) : $services->getMainConfig() + ); + $linkRendererFactory = func_num_args() > 7 ? func_get_arg( 7 ) : null; + $nsInfo = func_num_args() > 8 ? func_get_arg( 8 ) : null; + } else { + // New calling convention + $svcOptions->assertRequiredOptions( self::$constructorOptions ); + // $this->mConf is public, so we'll keep those two options there as well for + // compatibility until it's removed + $this->mConf = [ + 'class' => $svcOptions->get( 'class' ), + 'preprocessorClass' => $svcOptions->get( 'preprocessorClass' ), + ]; + $this->svcOptions = $svcOptions; + } + $this->mUrlProtocols = $urlProtocols ?? wfUrlProtocols(); $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' . self::EXT_LINK_ADDR . self::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su'; - if ( isset( $parserConf['preprocessorClass'] ) ) { - $this->mPreprocessorClass = $parserConf['preprocessorClass']; - } elseif ( wfIsHHVM() ) { - # Under HHVM Preprocessor_Hash is much faster than Preprocessor_DOM - $this->mPreprocessorClass = Preprocessor_Hash::class; - } elseif ( extension_loaded( 'domxml' ) ) { - # PECL extension that conflicts with the core DOM extension (T15770) - wfDebug( "Warning: you have the obsolete domxml extension for PHP. Please remove it!\n" ); - $this->mPreprocessorClass = Preprocessor_Hash::class; - } elseif ( extension_loaded( 'dom' ) ) { - $this->mPreprocessorClass = Preprocessor_DOM::class; - } else { - $this->mPreprocessorClass = Preprocessor_Hash::class; - } - wfDebug( __CLASS__ . ": using preprocessor: {$this->mPreprocessorClass}\n" ); - $services = MediaWikiServices::getInstance(); $this->magicWordFactory = $magicWordFactory ?? $services->getMagicWordFactory(); @@ -325,9 +378,7 @@ class Parser { $this->factory = $factory ?? $services->getParserFactory(); $this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory(); - $this->siteConfig = $siteConfig ?? $services->getMainConfig(); - $this->linkRendererFactory = - $linkRendererFactory ?? $services->getLinkRendererFactory(); + $this->linkRendererFactory = $linkRendererFactory ?? $services->getLinkRendererFactory(); $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo(); } @@ -366,6 +417,17 @@ class Parser { Hooks::run( 'ParserCloned', [ $this ] ); } + /** + * Which class should we use for the preprocessor if not otherwise specified? + * + * @since 1.34 + * @deprecated since 1.34, removing configurability of preprocessor + * @return string + */ + public static function getDefaultPreprocessorClass() { + return Preprocessor_Hash::class; + } + /** * Do various kinds of initialisation on the first call of the parser */ @@ -597,7 +659,7 @@ class Parser { Hooks::run( 'ParserLimitReportPrepare', [ $this, $this->mOutput ] ); $limitReport = "NewPP limit report\n"; - if ( $this->siteConfig->get( 'ShowHostnames' ) ) { + if ( $this->svcOptions->get( 'ShowHostnames' ) ) { $limitReport .= 'Parsed by ' . wfHostname() . "\n"; } $limitReport .= 'Cached time: ' . $this->mOutput->getCacheTime() . "\n"; @@ -605,6 +667,7 @@ class Parser { $limitReport .= 'Dynamic content: ' . ( $this->mOutput->hasDynamicContent() ? 'true' : 'false' ) . "\n"; + $limitReport .= 'Complications: [' . implode( ', ', $this->mOutput->getAllFlags() ) . "]\n"; foreach ( $this->mOutput->getLimitReportData() as $key => $value ) { if ( Hooks::run( 'ParserLimitReportFormat', @@ -648,7 +711,7 @@ class Parser { $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport ); // Add other cache related metadata - if ( $this->siteConfig->get( 'ShowHostnames' ) ) { + if ( $this->svcOptions->get( 'ShowHostnames' ) ) { $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() ); } $this->mOutput->setLimitReportData( 'cachereport-timestamp', @@ -969,7 +1032,7 @@ class Parser { */ public function getPreprocessor() { if ( !isset( $this->mPreprocessor ) ) { - $class = $this->mPreprocessorClass; + $class = $this->svcOptions->get( 'preprocessorClass' ); $this->mPreprocessor = new $class( $this ); } return $this->mPreprocessor; @@ -2386,7 +2449,7 @@ class Parser { if ( $iw && $this->mOptions->getInterwikiMagic() && $nottalk && ( Language::fetchLanguageName( $iw, null, 'mw' ) || - in_array( $iw, $this->siteConfig->get( 'ExtraInterlanguageLinkPrefixes' ) ) + in_array( $iw, $this->svcOptions->get( 'ExtraInterlanguageLinkPrefixes' ) ) ) ) { # T26502: filter duplicates @@ -2712,13 +2775,13 @@ class Parser { # The vary-revision flag must be set, because the magic word # will have a different value once the page is saved. $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision...\n" ); + wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision" ); } $value = $pageid ?: null; break; case 'revisionid': if ( - $this->siteConfig->get( 'MiserMode' ) && + $this->svcOptions->get( 'MiserMode' ) && !$this->mOptions->getInterfaceMessage() && // @TODO: disallow this word on all namespaces $this->nsInfo->isContent( $this->mTitle->getNamespace() ) @@ -2729,13 +2792,14 @@ class Parser { $value = '-'; } else { $this->mOutput->setFlag( 'vary-revision-exists' ); + wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-exists" ); $value = ''; } } else { # Inform the edit saving system that getting the canonical output after # revision insertion requires another parse using the actual revision ID $this->mOutput->setFlag( 'vary-revision-id' ); - wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" ); + wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id" ); $value = $this->getRevisionId(); if ( $value === 0 ) { $rev = $this->getRevisionObject(); @@ -2765,17 +2829,13 @@ class Parser { $value = $this->getRevisionTimestampSubstring( 0, 4, self::MAX_TTS, $index ); break; case 'revisiontimestamp': - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned. This is for null edits. - $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": {{REVISIONTIMESTAMP}} used, setting vary-revision...\n" ); - $value = $this->getRevisionTimestamp(); + $value = $this->getRevisionTimestampSubstring( 0, 14, self::MAX_TTS, $index ); break; case 'revisionuser': - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned for null edits. + # Inform the edit saving system that getting the canonical output after + # revision insertion requires a parse that used the actual user ID $this->mOutput->setFlag( 'vary-user' ); - wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user...\n" ); + wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user" ); $value = $this->getRevisionUser(); break; case 'revisionsize': @@ -2882,21 +2942,21 @@ class Parser { $value = SpecialVersion::getVersion(); break; case 'articlepath': - return $this->siteConfig->get( 'ArticlePath' ); + return $this->svcOptions->get( 'ArticlePath' ); case 'sitename': - return $this->siteConfig->get( 'Sitename' ); + return $this->svcOptions->get( 'Sitename' ); case 'server': - return $this->siteConfig->get( 'Server' ); + return $this->svcOptions->get( 'Server' ); case 'servername': - return $this->siteConfig->get( 'ServerName' ); + return $this->svcOptions->get( 'ServerName' ); case 'scriptpath': - return $this->siteConfig->get( 'ScriptPath' ); + return $this->svcOptions->get( 'ScriptPath' ); case 'stylepath': - return $this->siteConfig->get( 'StylePath' ); + return $this->svcOptions->get( 'StylePath' ); case 'directionmark': return $pageLang->getDirMark(); case 'contentlanguage': - return $this->siteConfig->get( 'LanguageCode' ); + return $this->svcOptions->get( 'LanguageCode' ); case 'pagelanguage': $value = $pageLang->getCode(); break; @@ -2923,7 +2983,7 @@ class Parser { /** * @param int $start * @param int $len - * @param int $mtts Max time-till-save; sets vary-revision if result might change by then + * @param int $mtts Max time-till-save; sets vary-revision-timestamp if result changes by then * @param string $variable Parser variable name * @return string */ @@ -2932,7 +2992,10 @@ class Parser { $resNow = substr( $this->getRevisionTimestamp(), $start, $len ); # Possibly set vary-revision if there is not yet an associated revision if ( !$this->getRevisionObject() ) { - # Get the timezone-adjusted timestamp $mtts seconds in the future + # Get the timezone-adjusted timestamp $mtts seconds in the future. + # This future is relative to the current time and not that of the + # parser options. The rendered timestamp can be compared to that + # of the timestamp specified by the parser options. $resThen = substr( $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ), $start, @@ -2940,10 +3003,10 @@ class Parser { ); if ( $resNow !== $resThen ) { - # Let the edit saving system know we should parse the page - # *after* a revision ID has been assigned. This is for null edits. - $this->mOutput->setFlag( 'vary-revision' ); - wfDebug( __METHOD__ . ": $variable used, setting vary-revision...\n" ); + # Inform the edit saving system that getting the canonical output after + # revision insertion requires a parse that used an actual revision timestamp + $this->mOutput->setFlag( 'vary-revision-timestamp' ); + wfDebug( __METHOD__ . ": $variable used, setting vary-revision-timestamp" ); } } @@ -3665,6 +3728,7 @@ class Parser { // If we transclude ourselves, the final result // will change based on the new version of the page $this->mOutput->setFlag( 'vary-revision' ); + wfDebug( __METHOD__ . ": self transclusion, setting vary-revision" ); } } } @@ -3773,19 +3837,6 @@ class Parser { 'deps' => $deps ]; } - /** - * Fetch a file and its title and register a reference to it. - * If 'broken' is a key in $options then the file will appear as a broken thumbnail. - * @param Title $title - * @param array $options Array of options to RepoGroup::findFile - * @return File|bool - * @deprecated since 1.32, use fetchFileAndTitle instead - */ - public function fetchFile( $title, $options = [] ) { - wfDeprecated( __METHOD__, '1.32' ); - return $this->fetchFileAndTitle( $title, $options )[0]; - } - /** * Fetch a file and its title and register a reference to it. * If 'broken' is a key in $options then the file will appear as a broken thumbnail. @@ -3824,7 +3875,7 @@ class Parser { } elseif ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp) $file = RepoGroup::singleton()->findFileFromKey( $options['sha1'], $options ); } else { // get by (name,timestamp) - $file = wfFindFile( $title, $options ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title, $options ); } return $file; } @@ -3838,7 +3889,7 @@ class Parser { * @return string */ public function interwikiTransclude( $title, $action ) { - if ( !$this->siteConfig->get( 'EnableScaryTranscluding' ) ) { + if ( !$this->svcOptions->get( 'EnableScaryTranscluding' ) ) { return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text(); } @@ -3858,7 +3909,7 @@ class Parser { ( $wikiId !== false ) ? $wikiId : 'external', sha1( $url ) ), - $this->siteConfig->get( 'TranscludeCacheExpiry' ), + $this->svcOptions->get( 'TranscludeCacheExpiry' ), function ( $oldValue, &$ttl ) use ( $url, $fname, $cache ) { $req = MWHttpRequest::factory( $url, [], $fname ); @@ -4230,7 +4281,7 @@ class Parser { $headlines = $numMatches !== false ? $matches[3] : []; - $maxTocLevel = $this->siteConfig->get( 'MaxTocLevel' ); + $maxTocLevel = $this->svcOptions->get( 'MaxTocLevel' ); foreach ( $headlines as $headline ) { $isTemplate = false; $titleText = false; @@ -4684,7 +4735,7 @@ class Parser { $nickname = $nickname == null ? $username : $nickname; - if ( mb_strlen( $nickname ) > $this->siteConfig->get( 'MaxSigChars' ) ) { + if ( mb_strlen( $nickname ) > $this->svcOptions->get( 'MaxSigChars' ) ) { $nickname = $username; wfDebug( __METHOD__ . ": $username has overlong signature.\n" ); } elseif ( $fancySig !== false ) { @@ -5842,7 +5893,7 @@ class Parser { * * The return value will be either: * - a) Positive, indicating a specific revision ID (current or old) - * - b) Zero, meaning the revision ID specified by getCurrentRevisionCallback() + * - b) Zero, meaning the revision ID is specified by getCurrentRevisionCallback() * - c) Null, meaning the parse is for preview mode and there is no revision * * @return int|null @@ -5897,20 +5948,25 @@ class Parser { /** * Get the timestamp associated with the current revision, adjusted for * the default server-local timestamp - * @return string + * @return string TS_MW timestamp */ public function getRevisionTimestamp() { - if ( is_null( $this->mRevisionTimestamp ) ) { - $revObject = $this->getRevisionObject(); - $timestamp = $revObject ? $revObject->getTimestamp() : wfTimestampNow(); - - # The cryptic '' timezone parameter tells to use the site-default - # timezone offset instead of the user settings. - # Since this value will be saved into the parser cache, served - # to other users, and potentially even used inside links and such, - # it needs to be consistent for all visitors. - $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' ); + if ( $this->mRevisionTimestamp !== null ) { + return $this->mRevisionTimestamp; } + + # Use specified revision timestamp, falling back to the current timestamp + $revObject = $this->getRevisionObject(); + $timestamp = $revObject ? $revObject->getTimestamp() : $this->mOptions->getTimestamp(); + $this->mOutput->setRevisionTimestampUsed( $timestamp ); // unadjusted time zone + + # The cryptic '' timezone parameter tells to use the site-default + # timezone offset instead of the user settings. + # Since this value will be saved into the parser cache, served + # to other users, and potentially even used inside links and such, + # it needs to be consistent for all visitors. + $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' ); + return $this->mRevisionTimestamp; } @@ -6005,7 +6061,7 @@ class Parser { } private function makeLegacyAnchor( $sectionName ) { - $fragmentMode = $this->siteConfig->get( 'FragmentMode' ); + $fragmentMode = $this->svcOptions->get( 'FragmentMode' ); if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) { // ForAttribute() and ForLink() are the same for legacy encoding $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK ); diff --git a/includes/parser/ParserCache.php b/includes/parser/ParserCache.php index 8e8cd98c38..2585872c2f 100644 --- a/includes/parser/ParserCache.php +++ b/includes/parser/ParserCache.php @@ -57,6 +57,7 @@ class ParserCache { * @var string */ private $cacheEpoch; + /** * Get an instance of this object * diff --git a/includes/parser/ParserFactory.php b/includes/parser/ParserFactory.php index cddacf43c5..0446d9c640 100644 --- a/includes/parser/ParserFactory.php +++ b/includes/parser/ParserFactory.php @@ -18,6 +18,8 @@ * @file * @ingroup Parser */ + +use MediaWiki\Config\ServiceOptions; use MediaWiki\Linker\LinkRendererFactory; use MediaWiki\MediaWikiServices; use MediaWiki\Special\SpecialPageFactory; @@ -26,8 +28,8 @@ use MediaWiki\Special\SpecialPageFactory; * @since 1.32 */ class ParserFactory { - /** @var array */ - private $parserConf; + /** @var ServiceOptions */ + private $svcOptions; /** @var MagicWordFactory */ private $magicWordFactory; @@ -41,9 +43,6 @@ class ParserFactory { /** @var SpecialPageFactory */ private $specialPageFactory; - /** @var Config */ - private $siteConfig; - /** @var LinkRendererFactory */ private $linkRendererFactory; @@ -51,31 +50,61 @@ class ParserFactory { private $nsInfo; /** - * @param array $parserConf See $wgParserConf documentation + * Old parameter list, which we support for backwards compatibility, were: + * array $parserConf See $wgParserConf documentation + * MagicWordFactory $magicWordFactory + * Language $contLang Content language + * string $urlProtocols As returned from wfUrlProtocols() + * SpecialPageFactory $spFactory + * Config $siteConfig + * LinkRendererFactory $linkRendererFactory + * NamespaceInfo|null $nsInfo + * + * Some type declarations were intentionally omitted so that the backwards compatibility code + * would work. When backwards compatibility is no longer required, we should remove it, and + * and add the omitted type declarations. + * + * @param ServiceOptions|array $svcOptions * @param MagicWordFactory $magicWordFactory * @param Language $contLang Content language * @param string $urlProtocols As returned from wfUrlProtocols() * @param SpecialPageFactory $spFactory - * @param Config $siteConfig * @param LinkRendererFactory $linkRendererFactory - * @param NamespaceInfo|null $nsInfo + * @param NamespaceInfo|LinkRendererFactory|null $nsInfo * @since 1.32 */ public function __construct( - array $parserConf, MagicWordFactory $magicWordFactory, Language $contLang, $urlProtocols, - SpecialPageFactory $spFactory, Config $siteConfig, - LinkRendererFactory $linkRendererFactory, NamespaceInfo $nsInfo = null + $svcOptions, MagicWordFactory $magicWordFactory, Language $contLang, + $urlProtocols, SpecialPageFactory $spFactory, $linkRendererFactory, + $nsInfo = null ) { + // @todo Do we need to retain compat for constructing this class directly? if ( !$nsInfo ) { wfDeprecated( __METHOD__ . ' with no NamespaceInfo argument', '1.34' ); $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); } - $this->parserConf = $parserConf; + if ( $linkRendererFactory instanceof Config ) { + // Old calling convention had an array in the format of $wgParserConf as the first + // parameter, and a Config as the sixth, with LinkRendererFactory as the seventh. + wfDeprecated( __METHOD__ . ' with Config parameter', '1.34' ); + $svcOptions = new ServiceOptions( Parser::$constructorOptions, + $svcOptions, + [ 'class' => Parser::class, + 'preprocessorClass' => Parser::getDefaultPreprocessorClass() ], + func_get_arg( 5 ) + ); + $linkRendererFactory = func_get_arg( 6 ); + $nsInfo = func_num_args() > 7 ? func_get_arg( 7 ) : null; + } + $svcOptions->assertRequiredOptions( Parser::$constructorOptions ); + + wfDebug( __CLASS__ . ": using preprocessor: {$svcOptions->get( 'preprocessorClass' )}\n" ); + + $this->svcOptions = $svcOptions; $this->magicWordFactory = $magicWordFactory; $this->contLang = $contLang; $this->urlProtocols = $urlProtocols; $this->specialPageFactory = $spFactory; - $this->siteConfig = $siteConfig; $this->linkRendererFactory = $linkRendererFactory; $this->nsInfo = $nsInfo; } @@ -85,8 +114,8 @@ class ParserFactory { * @since 1.32 */ public function create() : Parser { - return new Parser( $this->parserConf, $this->magicWordFactory, $this->contLang, $this, - $this->urlProtocols, $this->specialPageFactory, $this->siteConfig, - $this->linkRendererFactory, $this->nsInfo ); + return new Parser( $this->svcOptions, $this->magicWordFactory, $this->contLang, $this, + $this->urlProtocols, $this->specialPageFactory, $this->linkRendererFactory, + $this->nsInfo ); } } diff --git a/includes/parser/ParserOptions.php b/includes/parser/ParserOptions.php index 66b1612245..709f159bb0 100644 --- a/includes/parser/ParserOptions.php +++ b/includes/parser/ParserOptions.php @@ -895,7 +895,7 @@ class ParserOptions { /** * Timestamp used for {{CURRENTDAY}} etc. - * @return string + * @return string TS_MW timestamp */ public function getTimestamp() { if ( !isset( $this->mTimestamp ) ) { @@ -913,27 +913,6 @@ class ParserOptions { return wfSetVar( $this->mTimestamp, $x ); } - /** - * Create "edit section" links? - * @deprecated since 1.31, use ParserOutput::getText() options instead. - * @return bool - */ - public function getEditSection() { - wfDeprecated( __METHOD__, '1.31' ); - return true; - } - - /** - * Create "edit section" links? - * @deprecated since 1.31, use ParserOutput::getText() options instead. - * @param bool|null $x New value (null is no change) - * @return bool Old value - */ - public function setEditSection( $x ) { - wfDeprecated( __METHOD__, '1.31' ); - return true; - } - /** * Set the redirect target. * diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php index 9a1653db3d..c8113f38be 100644 --- a/includes/parser/ParserOutput.php +++ b/includes/parser/ParserOutput.php @@ -26,8 +26,14 @@ class ParserOutput extends CacheTime { /** * Feature flags to indicate to extensions that MediaWiki core supports and * uses getText() stateless transforms. + * + * @since 1.31 */ const SUPPORTS_STATELESS_TRANSFORMS = 1; + + /** + * @since 1.31 + */ const SUPPORTS_UNWRAP_TRANSFORM = 1; /** @@ -207,6 +213,9 @@ class ParserOutput extends CacheTime { /** @var int|null Assumed rev ID for {{REVISIONID}} if no revision is set */ private $mSpeculativeRevId; + /** @var int|null Assumed rev timestamp for {{REVISIONTIMESTAMP}} if no revision is set */ + private $revisionTimestampUsed; + /** string CSS classes to use for the wrapping div, stored in the array keys. * If no class is given, no wrapper is added. */ @@ -439,6 +448,22 @@ class ParserOutput extends CacheTime { return $this->mSpeculativeRevId; } + /** + * @param string $timestamp TS_MW timestamp + * @since 1.34 + */ + public function setRevisionTimestampUsed( $timestamp ) { + $this->revisionTimestampUsed = $timestamp; + } + + /** + * @return string|null TS_MW timestamp or null if not used + * @since 1.34 + */ + public function getRevisionTimestampUsed() { + return $this->revisionTimestampUsed; + } + public function &getLanguageLinks() { return $this->mLanguageLinks; } @@ -498,6 +523,7 @@ class ParserOutput extends CacheTime { public function setNoGallery( $value ) { $this->mNoGallery = (bool)$value; } + public function getNoGallery() { return $this->mNoGallery; } @@ -510,11 +536,6 @@ class ParserOutput extends CacheTime { return $this->mModules; } - public function getModuleScripts() { - wfDeprecated( __METHOD__, '1.33' ); - return []; - } - public function getModuleStyles() { return $this->mModuleStyles; } @@ -633,12 +654,15 @@ class ParserOutput extends CacheTime { public function setNewSection( $value ) { $this->mNewSection = (bool)$value; } + public function hideNewSection( $value ) { $this->mHideNewSection = (bool)$value; } + public function getHideNewSection() { return (bool)$this->mHideNewSection; } + public function getNewSection() { return (bool)$this->mNewSection; } @@ -896,17 +920,30 @@ class ParserOutput extends CacheTime { } /** - * Fairly generic flag setter thingy. + * Attach a flag to the output so that it can be checked later to handle special cases + * * @param string $flag */ public function setFlag( $flag ) { $this->mFlags[$flag] = true; } + /** + * @param string $flag + * @return bool Whether the given flag was set to signify a special case + */ public function getFlag( $flag ) { return isset( $this->mFlags[$flag] ); } + /** + * @return string[] List of flags signifying special cases + * @since 1.34 + */ + public function getAllFlags() { + return array_keys( $this->mFlags ); + } + /** * Set a property to be stored in the page_props database table. * diff --git a/includes/parser/Preprocessor_DOM.php b/includes/parser/Preprocessor_DOM.php index 0f0496beac..9e510d21d6 100644 --- a/includes/parser/Preprocessor_DOM.php +++ b/includes/parser/Preprocessor_DOM.php @@ -19,6 +19,7 @@ * * @file * @ingroup Parser + * @deprecated since 1.34, use Preprocessor_Hash */ /** @@ -37,6 +38,7 @@ class Preprocessor_DOM extends Preprocessor { const CACHE_PREFIX = 'preprocess-xml'; public function __construct( $parser ) { + wfDeprecated( __METHOD__, '1.34' ); // T204945 $this->parser = $parser; $mem = ini_get( 'memory_limit' ); $this->memoryLimit = false; diff --git a/includes/parser/RemexStripTagHandler.php b/includes/parser/RemexStripTagHandler.php index 2d75c869ec..cb85627e13 100644 --- a/includes/parser/RemexStripTagHandler.php +++ b/includes/parser/RemexStripTagHandler.php @@ -9,6 +9,7 @@ use RemexHtml\Tokenizer\Tokenizer; */ class RemexStripTagHandler implements TokenHandler { private $text = ''; + public function getResult() { return $this->text; } @@ -16,15 +17,19 @@ class RemexStripTagHandler implements TokenHandler { function startDocument( Tokenizer $t, $fns, $fn ) { // Do nothing. } + function endDocument( $pos ) { // Do nothing. } + function error( $text, $pos ) { // Do nothing. } + function characters( $text, $start, $length, $sourceStart, $sourceLength ) { $this->text .= substr( $text, $start, $length ); } + function startTag( $name, Attributes $attrs, $selfClose, $sourceStart, $sourceLength ) { // Inject whitespace for typical block-level tags to // prevent merging unrelated
words. @@ -32,6 +37,7 @@ class RemexStripTagHandler implements TokenHandler { $this->text .= ' '; } } + function endTag( $name, $sourceStart, $sourceLength ) { // Inject whitespace for typical block-level tags to // prevent merging unrelated
words. @@ -39,9 +45,11 @@ class RemexStripTagHandler implements TokenHandler { $this->text .= ' '; } } + function doctype( $name, $public, $system, $quirks, $sourceStart, $sourceLength ) { // Do nothing. } + function comment( $text, $sourceStart, $sourceLength ) { // Do nothing. } diff --git a/includes/parser/Sanitizer.php b/includes/parser/Sanitizer.php index abf071414b..8e0cf5c877 100644 --- a/includes/parser/Sanitizer.php +++ b/includes/parser/Sanitizer.php @@ -790,7 +790,7 @@ class Sanitizer { */ static function validateTagAttributes( $attribs, $element ) { return self::validateAttributes( $attribs, - self::attributeWhitelist( $element ) ); + self::attributeWhitelistInternal( $element ) ); } /** @@ -802,14 +802,21 @@ class Sanitizer { * - Invalid id attributes are re-encoded * * @param array $attribs - * @param array $whitelist List of allowed attribute names + * @param array $whitelist List of allowed attribute names, + * either as a sequential array of valid attribute names or + * as an associative array where keys give valid attribute names * @return array * * @todo Check for legal values where the DTD limits things. * @todo Check for unique id attribute :P */ static function validateAttributes( $attribs, $whitelist ) { - $whitelist = array_flip( $whitelist ); + if ( isset( $whitelist[0] ) ) { + // We would like to eventually deprecate calling this + // function with a sequential array, but for now just + // convert it. + $whitelist = array_flip( $whitelist ); + } $hrefExp = '/^(' . wfUrlProtocols() . ')[^\s]+$/'; $out = []; @@ -828,10 +835,10 @@ class Sanitizer { # * Disallow data attributes used by MediaWiki code # * Ensure that the attribute is not namespaced by banning # colons. - if ( !preg_match( '/^data-[^:]*$/i', $attribute ) - && !isset( $whitelist[$attribute] ) - || self::isReservedDataAttribute( $attribute ) - ) { + if ( ( + !preg_match( '/^data-[^:]*$/i', $attribute ) && + !array_key_exists( $attribute, $whitelist ) + ) || self::isReservedDataAttribute( $attribute ) ) { continue; } @@ -1073,6 +1080,7 @@ class Sanitizer { | image\s*\( | image-set\s*\( | attr\s*\([^)]+[\s,]+url + | var\s*\( !ix', $value ) ) { return '/* insecure input */'; } @@ -1244,7 +1252,7 @@ class Sanitizer { * HTML5 definition of id attribute * * @param string $id Id to escape - * @param string|array $options String or array of strings (default is array()): + * @param string|array $options String or array of strings (default is []): * 'noninitial': This is a non-initial fragment of an id, not a full id, * so don't pay attention if the first character isn't valid at the * beginning of an id. @@ -1745,26 +1753,63 @@ class Sanitizer { * Fetch the whitelist of acceptable attributes for a given element name. * * @param string $element - * @return array + * @return array A sequential array of acceptable attribute names + * @deprecated since 1.34; should be private */ static function attributeWhitelist( $element ) { + wfDeprecated( __METHOD__, '1.34' ); $list = self::setupAttributeWhitelist(); return $list[$element] ?? []; } + /** + * Fetch the whitelist of acceptable attributes for a given element name. + * + * @param string $element + * @return array An associative array where keys are acceptable attribute + * names + */ + private static function attributeWhitelistInternal( $element ) { + $list = self::setupAttributeWhitelistInternal(); + return $list[$element] ?? []; + } + /** * Foreach array key (an allowed HTML element), return an array * of allowed attributes * @return array + * @deprecated since 1.34; should be private */ static function setupAttributeWhitelist() { + wfDeprecated( __METHOD__, '1.34' ); + $wlist = self::setupAttributeWhitelistInternal(); + // This method is expected to return a sequential array as the + // value for each HTML element key. + return array_map( function ( $v ) { + return array_keys( $v ); + }, $wlist ); + } + + /** + * Foreach array key (an allowed HTML element), return an array + * of allowed attributes + * @return array An associative array: keys are HTML element names; + * values are associative arrays where the keys are allowed attribute + * names. + */ + private static function setupAttributeWhitelistInternal() { static $whitelist; if ( $whitelist !== null ) { return $whitelist; } - $common = [ + // For lookup efficiency flip each attributes array so the keys are + // the valid attributes. + $merge = function ( $a, $b, $c = [] ) { + return array_merge( $a, array_flip( $b ), array_flip( $c ) ); + }; + $common = $merge( [], [ # HTML 'id', 'class', @@ -1797,9 +1842,10 @@ class Sanitizer { 'itemref', 'itemscope', 'itemtype', - ]; + ] ); + + $block = $merge( $common, [ 'align' ] ); - $block = array_merge( $common, [ 'align' ] ); $tablealign = [ 'align', 'valign' ]; $tablecell = [ 'abbr', @@ -1849,8 +1895,8 @@ class Sanitizer { # acronym # 9.2.2 - 'blockquote' => array_merge( $common, [ 'cite' ] ), - 'q' => array_merge( $common, [ 'cite' ] ), + 'blockquote' => $merge( $common, [ 'cite' ] ), + 'q' => $merge( $common, [ 'cite' ] ), # 9.2.3 'sub' => $common, @@ -1860,22 +1906,22 @@ class Sanitizer { 'p' => $block, # 9.3.2 - 'br' => array_merge( $common, [ 'clear' ] ), + 'br' => $merge( $common, [ 'clear' ] ), # https://www.w3.org/TR/html5/text-level-semantics.html#the-wbr-element 'wbr' => $common, # 9.3.4 - 'pre' => array_merge( $common, [ 'width' ] ), + 'pre' => $merge( $common, [ 'width' ] ), # 9.4 - 'ins' => array_merge( $common, [ 'cite', 'datetime' ] ), - 'del' => array_merge( $common, [ 'cite', 'datetime' ] ), + 'ins' => $merge( $common, [ 'cite', 'datetime' ] ), + 'del' => $merge( $common, [ 'cite', 'datetime' ] ), # 10.2 - 'ul' => array_merge( $common, [ 'type' ] ), - 'ol' => array_merge( $common, [ 'type', 'start', 'reversed' ] ), - 'li' => array_merge( $common, [ 'type', 'value' ] ), + 'ul' => $merge( $common, [ 'type' ] ), + 'ol' => $merge( $common, [ 'type', 'start', 'reversed' ] ), + 'li' => $merge( $common, [ 'type', 'value' ] ), # 10.3 'dl' => $common, @@ -1883,7 +1929,7 @@ class Sanitizer { 'dt' => $common, # 11.2.1 - 'table' => array_merge( $common, + 'table' => $merge( $common, [ 'summary', 'width', 'border', 'frame', 'rules', 'cellspacing', 'cellpadding', 'align', 'bgcolor', @@ -1898,31 +1944,31 @@ class Sanitizer { 'tbody' => $common, # 11.2.4 - 'colgroup' => array_merge( $common, [ 'span' ] ), - 'col' => array_merge( $common, [ 'span' ] ), + 'colgroup' => $merge( $common, [ 'span' ] ), + 'col' => $merge( $common, [ 'span' ] ), # 11.2.5 - 'tr' => array_merge( $common, [ 'bgcolor' ], $tablealign ), + 'tr' => $merge( $common, [ 'bgcolor' ], $tablealign ), # 11.2.6 - 'td' => array_merge( $common, $tablecell, $tablealign ), - 'th' => array_merge( $common, $tablecell, $tablealign ), + 'td' => $merge( $common, $tablecell, $tablealign ), + 'th' => $merge( $common, $tablecell, $tablealign ), # 12.2 # NOTE: is not allowed directly, but the attrib # whitelist is used from the Parser object - 'a' => array_merge( $common, [ 'href', 'rel', 'rev' ] ), # rel/rev esp. for RDFa + 'a' => $merge( $common, [ 'href', 'rel', 'rev' ] ), # rel/rev esp. for RDFa # 13.2 # Not usually allowed, but may be used for extension-style hooks # such as when it is rasterized, or if $wgAllowImageTag is # true - 'img' => array_merge( $common, [ 'alt', 'src', 'width', 'height', 'srcset' ] ), + 'img' => $merge( $common, [ 'alt', 'src', 'width', 'height', 'srcset' ] ), # Attributes for A/V tags added in T163583 / T133673 - 'audio' => array_merge( $common, [ 'controls', 'preload', 'width', 'height' ] ), - 'video' => array_merge( $common, [ 'poster', 'controls', 'preload', 'width', 'height' ] ), - 'source' => array_merge( $common, [ 'type', 'src' ] ), - 'track' => array_merge( $common, [ 'type', 'src', 'srclang', 'kind', 'label' ] ), + 'audio' => $merge( $common, [ 'controls', 'preload', 'width', 'height' ] ), + 'video' => $merge( $common, [ 'poster', 'controls', 'preload', 'width', 'height' ] ), + 'source' => $merge( $common, [ 'type', 'src' ] ), + 'track' => $merge( $common, [ 'type', 'src', 'srclang', 'kind', 'label' ] ), # 15.2.1 'tt' => $common, @@ -1935,11 +1981,11 @@ class Sanitizer { 'u' => $common, # 15.2.2 - 'font' => array_merge( $common, [ 'size', 'color', 'face' ] ), + 'font' => $merge( $common, [ 'size', 'color', 'face' ] ), # basefont # 15.3 - 'hr' => array_merge( $common, [ 'width' ] ), + 'hr' => $merge( $common, [ 'width' ] ), # HTML Ruby annotation text module, simple ruby only. # https://www.w3.org/TR/html5/text-level-semantics.html#the-ruby-element @@ -1947,13 +1993,13 @@ class Sanitizer { # rbc 'rb' => $common, 'rp' => $common, - 'rt' => $common, # array_merge( $common, array( 'rbspan' ) ), + 'rt' => $common, # $merge( $common, [ 'rbspan' ] ), 'rtc' => $common, # MathML root element, where used for extensions # 'title' may not be 100% valid here; it's XHTML # https://www.w3.org/TR/REC-MathML/ - 'math' => [ 'class', 'style', 'id', 'title' ], + 'math' => $merge( [], [ 'class', 'style', 'id', 'title' ] ), // HTML 5 section 4.5 'figure' => $common, @@ -1965,8 +2011,8 @@ class Sanitizer { # HTML5 elements, defined by: # https://html.spec.whatwg.org/multipage/semantics.html#the-data-element - 'data' => array_merge( $common, [ 'value' ] ), - 'time' => array_merge( $common, [ 'datetime' ] ), + 'data' => $merge( $common, [ 'value' ] ), + 'time' => $merge( $common, [ 'datetime' ] ), 'mark' => $common, // meta and link are only permitted by removeHTMLtags when Microdata @@ -1974,8 +2020,8 @@ class Sanitizer { // Also meta and link are only valid in WikiText as Microdata elements // (ie: validateTag rejects tags missing the attributes needed for Microdata) // So we don't bother including $common attributes that have no purpose. - 'meta' => [ 'itemprop', 'content' ], - 'link' => [ 'itemprop', 'href', 'title' ], + 'meta' => $merge( [], [ 'itemprop', 'content' ] ), + 'link' => $merge( [], [ 'itemprop', 'href', 'title' ] ), ]; return $whitelist; diff --git a/includes/password/LayeredParameterizedPassword.php b/includes/password/LayeredParameterizedPassword.php index 841305481a..f3d8d03e30 100644 --- a/includes/password/LayeredParameterizedPassword.php +++ b/includes/password/LayeredParameterizedPassword.php @@ -109,7 +109,7 @@ class LayeredParameterizedPassword extends ParameterizedPassword { foreach ( $this->config['types'] as $i => $type ) { if ( $i == 0 ) { continue; - }; + } // Construct pseudo-hash based on params and arguments /** @var ParameterizedPassword $passObj */ diff --git a/includes/preferences/DefaultPreferencesFactory.php b/includes/preferences/DefaultPreferencesFactory.php index 1f21c1bbbc..beed60b1ab 100644 --- a/includes/preferences/DefaultPreferencesFactory.php +++ b/includes/preferences/DefaultPreferencesFactory.php @@ -113,7 +113,7 @@ class DefaultPreferencesFactory implements PreferencesFactory { /** * Do not call this directly. Get it from MediaWikiServices. * - * @param array|Config $options Config accepted for backwards compatibility + * @param ServiceOptions|Config $options Config accepted for backwards compatibility * @param Language $contLang * @param AuthManager $authManager * @param LinkRenderer $linkRenderer @@ -496,18 +496,6 @@ class DefaultPreferencesFactory implements PreferencesFactory { } } - // Stuff from Language::getExtraUserToggles() - // FIXME is this dead code? $extraUserToggles doesn't seem to be defined for any language - $toggles = $this->contLang->getExtraUserToggles(); - - foreach ( $toggles as $toggle ) { - $defaultPreferences[$toggle] = [ - 'type' => 'toggle', - 'section' => 'personal/i18n', - 'label-message' => "tog-$toggle", - ]; - } - // show a preview of the old signature first $oldsigWikiText = MediaWikiServices::getInstance()->getParser()->preSaveTransform( '~~~', @@ -1294,6 +1282,23 @@ class DefaultPreferencesFactory implements PreferencesFactory { # Only show skins that aren't disabled in $wgSkipSkins $validSkinNames = Skin::getAllowedSkins(); + $allInstalledSkins = Skin::getSkinNames(); + + // Display the installed skin the user has specifically requested via useskin=…. + $useSkin = $context->getRequest()->getRawVal( 'useskin' ); + if ( isset( $allInstalledSkins[$useSkin] ) + && $context->msg( "skinname-$useSkin" )->exists() + ) { + $validSkinNames[$useSkin] = $useSkin; + } + + // Display the skin if the user has set it as a preference already before it was hidden. + $currentUserSkin = $user->getOption( 'skin' ); + if ( isset( $allInstalledSkins[$currentUserSkin] ) + && $context->msg( "skinname-$useSkin" )->exists() + ) { + $validSkinNames[$currentUserSkin] = $currentUserSkin; + } foreach ( $validSkinNames as $skinkey => &$skinname ) { $msg = $context->msg( "skinname-{$skinkey}" ); @@ -1505,6 +1510,14 @@ class DefaultPreferencesFactory implements PreferencesFactory { */ $htmlForm = new $formClass( $formDescriptor, $context, 'prefs' ); + // This allows users to opt-in to hidden skins. While this should be discouraged and is not + // discoverable, this allows users to still use hidden skins while preventing new users from + // adopting unsupported skins. If no useskin=… parameter was provided, it will not show up + // in the resulting URL. + $htmlForm->setAction( $context->getTitle()->getLocalURL( [ + 'useskin' => $context->getRequest()->getRawVal( 'useskin' ) + ] ) ); + $htmlForm->setModifiedUser( $user ); $htmlForm->setId( 'mw-prefs-form' ); $htmlForm->setAutocomplete( 'off' ); diff --git a/includes/preferences/Filter.php b/includes/preferences/Filter.php index 670dd5b046..9fd12e8493 100644 --- a/includes/preferences/Filter.php +++ b/includes/preferences/Filter.php @@ -21,7 +21,7 @@ namespace MediaWiki\Preferences; /** - * Base interface for user preference flters that work as a middleware between + * Base interface for user preference filters that work as a middleware between * storage and interface. */ interface Filter { diff --git a/includes/preferences/MultiUsernameFilter.php b/includes/preferences/MultiUsernameFilter.php index 2d8ae3c033..f1c095e600 100644 --- a/includes/preferences/MultiUsernameFilter.php +++ b/includes/preferences/MultiUsernameFilter.php @@ -67,7 +67,7 @@ class MultiUsernameFilter implements Filter { } /** - * Splits a newline separated list of user ids into a + * Splits a newline separated list of user ids into an array. * * @param string $str * @return int[] diff --git a/includes/rcfeed/FormattedRCFeed.php b/includes/rcfeed/FormattedRCFeed.php index afe900d09c..d0b7ae3220 100644 --- a/includes/rcfeed/FormattedRCFeed.php +++ b/includes/rcfeed/FormattedRCFeed.php @@ -53,6 +53,7 @@ abstract class FormattedRCFeed extends RCFeed { public function notify( RecentChange $rc, $actionComment = null ) { $params = $this->params; /** @var RCFeedFormatter $formatter */ + // @phan-suppress-next-line PhanTypeExpectedObjectOrClassName $formatter = is_object( $params['formatter'] ) ? $params['formatter'] : new $params['formatter']; $line = $formatter->getLine( $params, $rc, $actionComment ); diff --git a/includes/rcfeed/RedisPubSubFeedEngine.php b/includes/rcfeed/RedisPubSubFeedEngine.php index c954df1a5b..66b1529986 100644 --- a/includes/rcfeed/RedisPubSubFeedEngine.php +++ b/includes/rcfeed/RedisPubSubFeedEngine.php @@ -29,10 +29,10 @@ * * @par Example: * @code - * $wgRCFeeds['redis'] = array( + * $wgRCFeeds['redis'] = [ * 'formatter' => 'JSONRCFeedFormatter', * 'uri' => "redis://127.0.0.1:6379/rc.$wgDBname", - * ); + * ]; * @endcode * * @since 1.22 diff --git a/includes/registration/ExtensionJsonValidator.php b/includes/registration/ExtensionJsonValidator.php index ba5df52854..0d95b2277b 100644 --- a/includes/registration/ExtensionJsonValidator.php +++ b/includes/registration/ExtensionJsonValidator.php @@ -141,6 +141,12 @@ class ExtensionJsonValidator { } } + // Deprecated stuff + if ( isset( $data->ParserTestFiles ) ) { + // phpcs:ignore Generic.Files.LineLength.TooLong + $extraErrors[] = '[ParserTestFiles] DEPRECATED: see '; + } + $validator = new Validator; $validator->check( $data, (object)[ '$ref' => 'file://' . $schemaPath ] ); if ( $validator->isValid() && !$extraErrors ) { diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php index b474ddc766..e71de849c6 100644 --- a/includes/registration/ExtensionProcessor.php +++ b/includes/registration/ExtensionProcessor.php @@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor { protected static $coreAttributes = [ 'SkinOOUIThemes', 'TrackingCategories', + 'RestRoutes', ]; /** @@ -304,8 +305,76 @@ class ExtensionProcessor implements Processor { ]; } - public function getRequirements( array $info ) { - return $info['requires'] ?? []; + public function getRequirements( array $info, $includeDev ) { + // Quick shortcuts + if ( !$includeDev || !isset( $info['dev-requires'] ) ) { + return $info['requires'] ?? []; + } + + if ( !isset( $info['requires'] ) ) { + return $info['dev-requires'] ?? []; + } + + // OK, we actually have to merge everything + $merged = []; + + // Helper that combines version requirements by + // picking the non-null if one is, or combines + // the two. Note that it is not possible for + // both inputs to be null. + $pick = function ( $a, $b ) { + if ( $a === null ) { + return $b; + } elseif ( $b === null ) { + return $a; + } else { + return "$a $b"; + } + }; + + $req = $info['requires']; + $dev = $info['dev-requires']; + if ( isset( $req['MediaWiki'] ) || isset( $dev['MediaWiki'] ) ) { + $merged['MediaWiki'] = $pick( + $req['MediaWiki'] ?? null, + $dev['MediaWiki'] ?? null + ); + } + + $platform = array_merge( + array_keys( $req['platform'] ?? [] ), + array_keys( $dev['platform'] ?? [] ) + ); + if ( $platform ) { + foreach ( $platform as $pkey ) { + if ( $pkey === 'php' ) { + $value = $pick( + $req['platform']['php'] ?? null, + $dev['platform']['php'] ?? null + ); + } else { + // Prefer dev value, but these should be constant + // anyways (ext-* and ability-*) + $value = $dev['platform'][$pkey] ?? $req['platform'][$pkey]; + } + $merged['platform'][$pkey] = $value; + } + } + + foreach ( [ 'extensions', 'skins' ] as $thing ) { + $things = array_merge( + array_keys( $req[$thing] ?? [] ), + array_keys( $dev[$thing] ?? [] ) + ); + foreach ( $things as $name ) { + $merged[$thing][$name] = $pick( + $req[$thing][$name] ?? null, + $dev[$thing][$name] ?? null + ); + } + } + + return $merged; } protected function extractHooks( array $info ) { diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php index fb897314f3..9cae73c907 100644 --- a/includes/registration/ExtensionRegistry.php +++ b/includes/registration/ExtensionRegistry.php @@ -86,6 +86,13 @@ class ExtensionRegistry { */ protected $testAttributes = []; + /** + * Whether to check dev-requires + * + * @var bool + */ + protected $checkDev = false; + /** * @var ExtensionRegistry */ @@ -103,6 +110,14 @@ class ExtensionRegistry { return self::$instance; } + /** + * @since 1.34 + * @param bool $check + */ + public function setCheckDevRequires( $check ) { + $this->checkDev = $check; + } + /** * @param string $path Absolute path to the JSON file */ @@ -148,6 +163,7 @@ class ExtensionRegistry { 'registration' => self::CACHE_VERSION, 'mediawiki' => $wgVersion, 'abilities' => $this->getAbilities(), + 'checkDev' => $this->checkDev, ]; // We use a try/catch because we don't want to fail here @@ -284,6 +300,13 @@ class ExtensionRegistry { } $dir = dirname( $path ); + self::exportAutoloadClassesAndNamespaces( + $dir, + $info, + $autoloadClasses, + $autoloadNamespaces + ); + if ( isset( $info['AutoloadClasses'] ) ) { $autoload = $this->processAutoLoader( $dir, $info['AutoloadClasses'] ); $GLOBALS['wgAutoloadClasses'] += $autoload; @@ -295,7 +318,7 @@ class ExtensionRegistry { } // get all requirements/dependencies for this extension - $requires = $processor->getRequirements( $info ); + $requires = $processor->getRequirements( $info, $this->checkDev ); // validate the information needed and add the requirements if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) { @@ -331,6 +354,28 @@ class ExtensionRegistry { return $data; } + /** + * Export autoload classes and namespaces for a given directory and parsed JSON info file. + * + * @param string $dir + * @param array $info + * @param array &$autoloadClasses + * @param array &$autoloadNamespaces + */ + public static function exportAutoloadClassesAndNamespaces( + $dir, $info, &$autoloadClasses = [], &$autoloadNamespaces = [] + ) { + if ( isset( $info['AutoloadClasses'] ) ) { + $autoload = self::processAutoLoader( $dir, $info['AutoloadClasses'] ); + $GLOBALS['wgAutoloadClasses'] += $autoload; + $autoloadClasses += $autoload; + } + if ( isset( $info['AutoloadNamespaces'] ) ) { + $autoloadNamespaces += self::processAutoLoader( $dir, $info['AutoloadNamespaces'] ); + AutoLoader::$psr4Namespaces += $autoloadNamespaces; + } + } + protected function exportExtractedData( array $info ) { foreach ( $info['globals'] as $key => $val ) { // If a merge strategy is set, read it and remove it from the value @@ -495,7 +540,7 @@ class ExtensionRegistry { * @param array $files * @return array */ - protected function processAutoLoader( $dir, array $files ) { + protected static function processAutoLoader( $dir, array $files ) { // Make paths absolute, relative to the JSON file foreach ( $files as &$file ) { $file = "$dir/$file"; diff --git a/includes/registration/Processor.php b/includes/registration/Processor.php index 68ba4130a0..51cca365c8 100644 --- a/includes/registration/Processor.php +++ b/includes/registration/Processor.php @@ -36,10 +36,11 @@ interface Processor { * * @since 1.26 * @param array $info + * @param bool $includeDev * @return array Where keys are the name to have a constraint on, * like 'MediaWiki'. Values are a constraint string like "1.26.1". */ - public function getRequirements( array $info ); + public function getRequirements( array $info, $includeDev ); /** * Get the path for additional autoloaders, e.g. the one of Composer. diff --git a/includes/resourceloader/MessageBlobStore.php b/includes/resourceloader/MessageBlobStore.php index 635e4337d8..74d0616d1d 100644 --- a/includes/resourceloader/MessageBlobStore.php +++ b/includes/resourceloader/MessageBlobStore.php @@ -96,7 +96,7 @@ class MessageBlobStore implements LoggerAwareInterface { $cache = $this->wanCache; $checkKeys = [ // Global check key, see clear() - $cache->makeKey( __CLASS__ ) + $cache->makeGlobalKey( __CLASS__ ) ]; $cacheKeys = []; foreach ( $modules as $name => $module ) { @@ -173,9 +173,14 @@ class MessageBlobStore implements LoggerAwareInterface { */ public function clear() { $cache = $this->wanCache; - // Disable holdoff because this invalidates all modules and also not needed since - // LocalisationCache is stored outside the database and doesn't have lag. - $cache->touchCheckKey( $cache->makeKey( __CLASS__ ), $cache::HOLDOFF_NONE ); + // Disable hold-off because: + // - LocalisationCache is populated by messages on-disk and don't have DB lag, + // thus there is no need for hold off. We only clear it after new localisation + // updates are known to be deployed to all servers. + // - This global check key invalidates message blobs for all modules for all wikis + // in cache contexts (e.g. languages, skins). Setting a hold-off on this key could + // cause a cache stampede since no values would be stored for several seconds. + $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_NONE ); } /** diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 418f532af5..ec376e3a3c 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -118,11 +118,10 @@ class ResourceLoader implements LoggerAwareInterface { return; } $dbr = wfGetDB( DB_REPLICA ); - $skin = $context->getSkin(); $lang = $context->getLanguage(); // Batched version of ResourceLoaderModule::getFileDependencies - $vary = "$skin|$lang"; + $vary = ResourceLoaderModule::getVary( $context ); $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [ 'md_module' => $moduleNames, 'md_skin' => $vary, @@ -196,8 +195,7 @@ class ResourceLoader implements LoggerAwareInterface { $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); $key = $cache->makeGlobalKey( - 'resourceloader', - 'filter', + 'resourceloader-filter', $filter, self::CACHE_VERSION, md5( $data ) @@ -236,15 +234,14 @@ class ResourceLoader implements LoggerAwareInterface { /** * Register core modules and runs registration hooks. - * @param Config|null $config [optional] + * @param Config|null $config * @param LoggerInterface|null $logger [optional] */ public function __construct( Config $config = null, LoggerInterface $logger = null ) { $this->logger = $logger ?: new NullLogger(); if ( !$config ) { - // TODO: Deprecate and remove. - $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' ); + wfDeprecated( __METHOD__ . ' without a Config instance', '1.34' ); $config = MediaWikiServices::getInstance()->getMainConfig(); } $this->config = $config; @@ -1050,9 +1047,6 @@ MESSAGE; $states[$name] = 'missing'; } - // Generate output - $isRaw = false; - $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js'; foreach ( $modules as $name => $module ) { @@ -1131,12 +1125,11 @@ MESSAGE; $states[$name] = 'error'; unset( $modules[$name] ); } - $isRaw |= $module->isRaw(); } // Update module states - if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) { - if ( count( $modules ) && $context->getOnly() === 'scripts' ) { + if ( $context->shouldIncludeScripts() && !$context->getRaw() ) { + if ( $modules && $context->getOnly() === 'scripts' ) { // Set the state of modules loaded as only scripts to ready as // they don't have an mw.loader.implement wrapper that sets the state foreach ( $modules as $name => $module ) { @@ -1145,7 +1138,7 @@ MESSAGE; } // Set the state of modules we didn't respond to with mw.loader.implement - if ( count( $states ) ) { + if ( $states ) { $stateScript = self::makeLoaderStateScript( $states ); if ( !$context->getDebug() ) { $stateScript = self::filter( 'minify-js', $stateScript ); @@ -1710,7 +1703,6 @@ MESSAGE; * @param bool $printable * @param bool $handheld * @param array $extraQuery - * * @return array */ public static function makeLoaderQuery( $modules, $lang, $skin, $user = null, @@ -1719,9 +1711,17 @@ MESSAGE; ) { $query = [ 'modules' => self::makePackedModulesString( $modules ), - 'lang' => $lang, - 'skin' => $skin, ]; + // Keep urls short by omitting query parameters that + // match the defaults assumed by ResourceLoaderContext. + // Note: This relies on the defaults either being insignificant or forever constant, + // as otherwise cached urls could change in meaning when the defaults change. + if ( $lang !== ResourceLoaderContext::DEFAULT_LANG ) { + $query['lang'] = $lang; + } + if ( $skin !== ResourceLoaderContext::DEFAULT_SKIN ) { + $query['skin'] = $skin; + } if ( $debug === true ) { $query['debug'] = 'true'; } diff --git a/includes/resourceloader/ResourceLoaderCircularDependencyError.php b/includes/resourceloader/ResourceLoaderCircularDependencyError.php new file mode 100644 index 0000000000..7cd53fe126 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderCircularDependencyError.php @@ -0,0 +1,26 @@ + '1' ]; foreach ( [ 'target', 'safemode' ] as $param ) { if ( $this->options[$param] !== null ) { $startupQuery[$param] = (string)$this->options[$param]; @@ -348,7 +348,7 @@ JAVASCRIPT; private static function makeContext( ResourceLoaderContext $mainContext, $group, $type, array $extraQuery = [] ) { - // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw(). + // Create new ResourceLoaderContext so that $extraQuery is supported (eg. for 'sync=1'). $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) ); // Set 'only' if not combined $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type ); @@ -434,12 +434,6 @@ JAVASCRIPT; ); } } else { - // See if we have one or more raw modules - $isRaw = false; - foreach ( $moduleSet as $key => $module ) { - $isRaw |= $module->isRaw(); - } - // Special handling for the user group; because users might change their stuff // on-wiki like user pages, or user preferences; we need to find the highest // timestamp of these user-changeable modules so we can ensure cache misses on change @@ -455,9 +449,15 @@ JAVASCRIPT; // Decide whether to use 'style' or 'script' element if ( $only === ResourceLoaderModule::TYPE_STYLES ) { $chunk = Html::linkedStyle( $url ); - } elseif ( $context->getRaw() || $isRaw ) { + } elseif ( $context->getRaw() ) { + // This request is asking for the module to be delivered standalone, + // (aka "raw") without communicating to any mw.loader client. + // Use cases: + // - startup (naturally because this is what will define mw.loader) + // - html5shiv (loads synchronously in old IE before the async startup module arrives) + // - QUnit (needed in SpecialJavaScriptTest before async startup) $chunk = Html::element( 'script', [ - // In SpecialJavaScriptTest, QUnit must load synchronous + // The 'sync' option is only supported in combination with 'raw'. 'async' => !isset( $extraQuery['sync'] ), 'src' => $url ] ); diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php index 58152ea083..1f06ede1b7 100644 --- a/includes/resourceloader/ResourceLoaderContext.php +++ b/includes/resourceloader/ResourceLoaderContext.php @@ -30,6 +30,9 @@ use MediaWiki\MediaWikiServices; * of a specific loader request. */ class ResourceLoaderContext implements MessageLocalizer { + const DEFAULT_LANG = 'qqx'; + const DEFAULT_SKIN = 'fallback'; + protected $resourceLoader; protected $request; protected $logger; @@ -88,7 +91,7 @@ class ResourceLoaderContext implements MessageLocalizer { // The 'skin' parameter is required. (Not yet enforced.) // For requests without a known skin specified, // use MediaWiki's 'fallback' skin for skin-specific decisions. - $this->skin = 'fallback'; + $this->skin = self::DEFAULT_SKIN; } } @@ -134,6 +137,8 @@ class ResourceLoaderContext implements MessageLocalizer { } /** + * @deprecated since 1.34 Use ResourceLoaderModule::getConfig instead + * inside module methods. Use ResourceLoader::getConfig elsewhere. * @return Config */ public function getConfig() { @@ -148,6 +153,8 @@ class ResourceLoaderContext implements MessageLocalizer { } /** + * @deprecated since 1.34 Use ResourceLoaderModule::getLogger instead + * inside module methods. Use ResourceLoader::getLogger elsewhere. * @since 1.27 * @return \Psr\Log\LoggerInterface */ @@ -174,7 +181,7 @@ class ResourceLoaderContext implements MessageLocalizer { if ( !Language::isValidBuiltInCode( $lang ) ) { // The 'lang' parameter is required. (Not yet enforced.) // If omitted, localise with the dummy language code. - $lang = 'qqx'; + $lang = self::DEFAULT_LANG; } $this->language = $lang; } @@ -186,8 +193,10 @@ class ResourceLoaderContext implements MessageLocalizer { */ public function getDirection() { if ( $this->direction === null ) { - $this->direction = $this->getRequest()->getRawVal( 'dir' ); - if ( !$this->direction ) { + $direction = $this->getRequest()->getRawVal( 'dir' ); + if ( $direction === 'ltr' || $direction === 'rtl' ) { + $this->direction = $direction; + } else { // Determine directionality based on user language (T8100) $this->direction = Language::factory( $this->getLanguage() )->getDir(); } @@ -401,4 +410,24 @@ class ResourceLoaderContext implements MessageLocalizer { } return $this->hash; } + + /** + * Get the request base parameters, omitting any defaults. + * + * @internal For internal use by ResourceLoaderStartUpModule only + * @return array + */ + public function getReqBase() { + $reqBase = []; + if ( $this->getLanguage() !== self::DEFAULT_LANG ) { + $reqBase['lang'] = $this->getLanguage(); + } + if ( $this->getSkin() !== self::DEFAULT_SKIN ) { + $reqBase['skin'] = $this->getSkin(); + } + if ( $this->getDebug() ) { + $reqBase['debug'] = 'true'; + } + return $reqBase; + } } diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php index 031541b559..017b39934e 100644 --- a/includes/resourceloader/ResourceLoaderFileModule.php +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -24,6 +24,12 @@ /** * ResourceLoader module based on local JavaScript/CSS files. + * + * The following public methods can query the database: + * + * - getDefinitionSummary / … / ResourceLoaderModule::getFileDependencies. + * - getVersionHash / getDefinitionSummary / … / ResourceLoaderModule::getFileDependencies. + * - getStyles / ResourceLoaderModule::saveFileDependencies. */ class ResourceLoaderFileModule extends ResourceLoaderModule { @@ -134,9 +140,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { /** @var bool Link to raw files in debug mode */ protected $debugRaw = true; - /** @var bool Whether mw.loader.state() call should be omitted */ - protected $raw = false; - protected $targets = [ 'desktop' ]; /** @var bool Whether CSSJanus flipping should be skipped for this module */ @@ -299,7 +302,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { break; // Single booleans case 'debugRaw': - case 'raw': case 'noflip': $this->{$member} = (bool)$option; break; @@ -334,7 +336,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { * to $IP * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults * to $wgResourceBasePath - * @return array Array( localBasePath, remoteBasePath ) + * @return array [ localBasePath, remoteBasePath ] */ public static function extractBasePaths( $options = [], @@ -507,13 +509,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { return $contents; } - /** - * @return bool - */ - public function isRaw() { - return $this->raw; - } - /** * Disable module content versioning. * @@ -614,14 +609,28 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { 'templates', 'skipFunction', 'debugRaw', - 'raw', ] as $member ) { $options[$member] = $this->{$member}; - }; + } + + $packageFiles = $this->expandPackageFiles( $context ); + if ( $packageFiles ) { + // Extract the minimum needed: + // - The 'main' pointer (included as-is). + // - The 'files' array, simplied to only which files exist (the keys of + // this array), and something that represents their non-file content. + // For packaged files that reflect files directly from disk, the + // 'getFileHashes' method tracks this already. + // It is important that the keys of the 'files' array are preserved, + // as they affect the module output. + $packageFiles['files'] = array_map( function ( $fileInfo ) { + return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null ); + }, $packageFiles['files'] ); + } $summary[] = [ 'options' => $options, - 'packageFiles' => $this->expandPackageFiles( $context ), + 'packageFiles' => $packageFiles, 'fileHashes' => $this->getFileHashes( $context ), 'messageBlob' => $this->getMessageBlob( $context ), ]; @@ -983,7 +992,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { || $this->dependencies || $this->messages || $this->skipFunction - || $this->raw ); return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL; } @@ -1068,16 +1076,22 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } /** - * Expand the packageFiles definition into something that's (almost) the right format for - * getPackageFiles() to return. This expands shorthands, resolves config vars and callbacks, - * but does not expand file paths or read the actual contents of files. Those things are done - * by getPackageFiles(). + * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary(). + * + * This expands the 'packageFiles' definition into something that's (almost) the right format + * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles + * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes() + * handles it instead, which also ends up in getDefinitionSummary(). * - * This is split up in this way so that getFileHashes() can get a list of file names, and - * getDefinitionSummary() can get config vars and callback results in their expanded form. + * What it does not do is reading the actual contents of any specified files, nor invoking + * the computation callbacks. Those things are done by getPackageFiles() instead to improve + * backend performance by only doing this work when the module response is needed, and not + * when merely computing the version hash for StartupModule, or when checking + * If-None-Match headers for a HTTP 304 response. * * @param ResourceLoaderContext $context * @return array|null + * @throws MWException If the 'packageFiles' definition is invalid. */ private function expandPackageFiles( ResourceLoaderContext $context ) { $hash = $context->getHash(); @@ -1113,19 +1127,32 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } } + // Perform expansions (except 'file' and 'callback'), creating one of these keys: + // - 'content': literal value. + // - 'filePath': content to be read from a file. + // - 'callback': content computed by a callable. if ( isset( $fileInfo['content'] ) ) { $expanded['content'] = $fileInfo['content']; } elseif ( isset( $fileInfo['file'] ) ) { $expanded['filePath'] = $fileInfo['file']; } elseif ( isset( $fileInfo['callback'] ) ) { - if ( is_callable( $fileInfo['callback'] ) ) { - $expanded['content'] = $fileInfo['callback']( $context ); - } else { + if ( !is_callable( $fileInfo['callback'] ) ) { $msg = __METHOD__ . ": invalid callback for package file \"{$fileInfo['name']}\"" . " in module \"{$this->getName()}\""; wfDebugLog( 'resourceloader', $msg ); throw new MWException( $msg ); } + if ( isset( $fileInfo['versionCallback'] ) ) { + if ( !is_callable( $fileInfo['versionCallback'] ) ) { + throw new MWException( __METHOD__ . ": invalid versionCallback for file" . + " \"{$fileInfo['name']}\" in module \"{$this->getName()}\"" ); + } + $expanded['definitionSummary'] = ( $fileInfo['versionCallback'] )( $context ); + // Don't invoke 'callback' here as it may be expensive (T223260). + $expanded['callback'] = $fileInfo['callback']; + } else { + $expanded['content'] = ( $fileInfo['callback'] )( $context ); + } } elseif ( isset( $fileInfo['config'] ) ) { if ( $type !== 'data' ) { $msg = __METHOD__ . ": invalid use of \"config\" for package file \"{$fileInfo['name']}\" " . @@ -1184,6 +1211,8 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { // Expand file contents foreach ( $expandedPackageFiles['files'] as &$fileInfo ) { + // Turn any 'filePath' or 'callback' key into actual 'content', + // and remove the key after that. if ( isset( $fileInfo['filePath'] ) ) { $localPath = $this->getLocalPath( $fileInfo['filePath'] ); if ( !file_exists( $localPath ) ) { @@ -1198,7 +1227,13 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { } $fileInfo['content'] = $content; unset( $fileInfo['filePath'] ); + } elseif ( isset( $fileInfo['callback'] ) ) { + $fileInfo['content'] = ( $fileInfo['callback'] )( $context ); + unset( $fileInfo['callback'] ); } + + // Not needed for client response, exists for getDefinitionSummary(). + unset( $fileInfo['definitionSummary'] ); } return $expandedPackageFiles; diff --git a/includes/resourceloader/ResourceLoaderImage.php b/includes/resourceloader/ResourceLoaderImage.php index 2e2da70475..c1b3dc34ca 100644 --- a/includes/resourceloader/ResourceLoaderImage.php +++ b/includes/resourceloader/ResourceLoaderImage.php @@ -146,6 +146,7 @@ class ResourceLoaderImage { * * @param ResourceLoaderContext $context Any context * @return string + * @throws MWException If no matching path is found */ public function getPath( ResourceLoaderContext $context ) { $desc = $this->descriptor; @@ -167,7 +168,11 @@ class ResourceLoaderImage { if ( isset( $desc[$context->getDirection()] ) ) { return $this->basePath . '/' . $desc[$context->getDirection()]; } - return $this->basePath . '/' . $desc['default']; + if ( isset( $desc['default'] ) ) { + return $this->basePath . '/' . $desc['default']; + } else { + throw new MWException( 'No matching path found' ); + } } /** diff --git a/includes/resourceloader/ResourceLoaderImageModule.php b/includes/resourceloader/ResourceLoaderImageModule.php index 9b50d80f69..90b18ebd9c 100644 --- a/includes/resourceloader/ResourceLoaderImageModule.php +++ b/includes/resourceloader/ResourceLoaderImageModule.php @@ -39,7 +39,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { protected $origin = self::ORIGIN_CORE_SITEWIDE; - /** @var ResourceLoaderImage[]|null */ + /** @var ResourceLoaderImage[][]|null */ protected $imageObjects = null; /** @var array */ protected $images = []; @@ -113,7 +113,7 @@ class ResourceLoaderImageModule extends ResourceLoaderModule { * @throws InvalidArgumentException */ public function __construct( $options = [], $localBasePath = null ) { - $this->localBasePath = self::extractLocalBasePath( $options, $localBasePath ); + $this->localBasePath = static::extractLocalBasePath( $options, $localBasePath ); $this->definition = $options; } diff --git a/includes/resourceloader/ResourceLoaderLessVarFileModule.php b/includes/resourceloader/ResourceLoaderLessVarFileModule.php index c4e517ad00..0269ec3ddb 100644 --- a/includes/resourceloader/ResourceLoaderLessVarFileModule.php +++ b/includes/resourceloader/ResourceLoaderLessVarFileModule.php @@ -33,7 +33,7 @@ class ResourceLoaderLessVarFileModule extends ResourceLoaderFileModule { * * @param string $blob * @param array $exclusions - * @return array $blob + * @return object $blob */ protected function excludeMessagesFromBlob( $blob, $exclusions ) { $data = json_decode( $blob, true ); diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php index aca5c73c2b..c376fa7362 100644 --- a/includes/resourceloader/ResourceLoaderModule.php +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -146,10 +146,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { if ( is_string( $deprecationInfo ) ) { $warning .= "\n" . $deprecationInfo; } - return Xml::encodeJsCall( - 'mw.log.warn', - [ $warning ] - ); + return 'mw.log.warn(' . ResourceLoader::encodeJsonForScript( $warning ) . ');'; } else { return ''; } @@ -182,7 +179,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { /** * Takes named templates by the module and returns an array mapping. * - * @return array of templates mapping template alias to content + * @return string[] Array of templates mapping template alias to content */ public function getTemplates() { // Stub, override expected. @@ -338,17 +335,6 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { return 'local'; } - /** - * Whether this module's JS expects to work without the client-side ResourceLoader module. - * Returning true from this function will prevent mw.loader.state() call from being - * appended to the bottom of the script. - * - * @return bool - */ - public function isRaw() { - return false; - } - /** * Get a list of modules this module depends on. * @@ -412,7 +398,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { * @return array List of files */ protected function getFileDependencies( ResourceLoaderContext $context ) { - $vary = $context->getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); // Try in-object cache first if ( !isset( $this->fileDeps[$vary] ) ) { @@ -447,7 +433,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { * @param string[] $files Array of file names */ public function setFileDependencies( ResourceLoaderContext $context, $files ) { - $vary = $context->getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); $this->fileDeps[$vary] = $files; } @@ -484,7 +470,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { } // The file deps list has changed, we want to update it. - $vary = $context->getSkin() . '|' . $context->getLanguage(); + $vary = self::getVary( $context ); $cache = ObjectCache::getLocalClusterInstance(); $key = $cache->makeKey( __METHOD__, $this->getName(), $vary ); $scopeLock = $cache->getScopedLock( $key, 0 ); @@ -504,10 +490,11 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { 'md_skin' => $vary, 'md_deps' => $deps, ], - [ 'md_module', 'md_skin' ], + [ [ 'md_module', 'md_skin' ] ], [ 'md_deps' => $deps, - ] + ], + __METHOD__ ); if ( $dbw->trxLevel() ) { @@ -953,8 +940,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $cache->makeGlobalKey( - 'resourceloader', - 'jsparse', + 'resourceloader-jsparse', self::$parseCacheVersion, md5( $contents ), $fileName @@ -1020,4 +1006,18 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { protected static function safeFileHash( $filePath ) { return FileContentsHasher::getFileContentsHash( $filePath ); } + + /** + * Get vary string. + * + * @internal For internal use only. + * @param ResourceLoaderContext $context + * @return string Vary string + */ + public static function getVary( ResourceLoaderContext $context ) { + return implode( '|', [ + $context->getSkin(), + $context->getLanguage(), + ] ); + } } diff --git a/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php b/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php new file mode 100644 index 0000000000..c860362af7 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderOOUIIconPackModule.php @@ -0,0 +1,81 @@ +definition['icons'] ) || !$this->definition['icons'] ) { + throw new InvalidArgumentException( "Parameter 'icons' must be given." ); + } + + // A few things check for the "icons" prefix on this value, so specify it even though + // we don't use it for actually loading the data, like in the other modules. + $this->definition['themeImages'] = 'icons'; + } + + private function getIcons() { + return $this->definition['icons']; + } + + protected function loadOOUIDefinition( $theme, $unused ) { + // This is shared between instances of this class, so we only have to load the JSON files once + static $data = []; + + if ( !isset( $data[$theme] ) ) { + $data[$theme] = []; + // Load and merge the JSON data for all "icons-foo" modules + foreach ( self::$knownImagesModules as $module ) { + if ( substr( $module, 0, 5 ) === 'icons' ) { + $moreData = $this->readJSONFile( $this->getThemeImagesPath( $theme, $module ) ); + if ( $moreData ) { + $data[$theme] = array_replace_recursive( $data[$theme], $moreData ); + } + } + } + } + + $definition = $data[$theme]; + + // Filter out the data for all other icons, leaving only the ones we want for this module + $iconsNames = $this->getIcons(); + foreach ( array_keys( $definition['images'] ) as $iconName ) { + if ( !in_array( $iconName, $iconsNames ) ) { + unset( $definition['images'][$iconName] ); + } + } + + return $definition; + } + + public static function extractLocalBasePath( $options, $localBasePath = null ) { + global $IP; + if ( $localBasePath === null ) { + $localBasePath = $IP; + } + // Ignore any 'localBasePath' present in $options, this always refers to files in MediaWiki core + return $localBasePath; + } +} diff --git a/includes/resourceloader/ResourceLoaderOOUIImageModule.php b/includes/resourceloader/ResourceLoaderOOUIImageModule.php index 313d789249..34079c3b7b 100644 --- a/includes/resourceloader/ResourceLoaderOOUIImageModule.php +++ b/includes/resourceloader/ResourceLoaderOOUIImageModule.php @@ -19,7 +19,8 @@ */ /** - * Secret special sauce. + * Loads the module definition from JSON files in the format that OOUI uses, converting it to the + * format we use. (Previously known as secret special sauce.) * * @since 1.26 */ @@ -39,36 +40,12 @@ class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { $definition = []; foreach ( $themes as $skin => $theme ) { - // Find the path to the JSON file which contains the actual image definitions for this theme - if ( $module ) { - $dataPath = $this->getThemeImagesPath( $theme, $module ); - } else { - // Backwards-compatibility for things that probably shouldn't have used this class... - $dataPath = - $this->definition['rootPath'] . '/' . - strtolower( $theme ) . '/' . - $this->definition['name'] . '.json'; - } - $localDataPath = $this->localBasePath . '/' . $dataPath; + $data = $this->loadOOUIDefinition( $theme, $module ); - // If there's no file for this module of this theme, that's okay, it will just use the defaults - if ( !file_exists( $localDataPath ) ) { + if ( !$data ) { + // If there's no file for this module of this theme, that's okay, it will just use the defaults continue; } - $data = json_decode( file_get_contents( $localDataPath ), true ); - - // Expand the paths to images (since they are relative to the JSON file that defines them, not - // our base directory) - $fixPath = function ( &$path ) use ( $dataPath ) { - $path = dirname( $dataPath ) . '/' . $path; - }; - array_walk( $data['images'], function ( &$value ) use ( $fixPath ) { - if ( is_string( $value['file'] ) ) { - $fixPath( $value['file'] ); - } elseif ( is_array( $value['file'] ) ) { - array_walk_recursive( $value['file'], $fixPath ); - } - } ); // Convert into a definition compatible with the parent vanilla ResourceLoaderImageModule foreach ( $data as $key => $value ) { @@ -107,4 +84,59 @@ class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { parent::loadFromDefinition(); } + + /** + * Load the module definition from the JSON file(s) for the given theme and module. + * + * @since 1.34 + * @param string $theme + * @param string $module + * @return array + */ + protected function loadOOUIDefinition( $theme, $module ) { + // Find the path to the JSON file which contains the actual image definitions for this theme + if ( $module ) { + $dataPath = $this->getThemeImagesPath( $theme, $module ); + } else { + // Backwards-compatibility for things that probably shouldn't have used this class... + $dataPath = + $this->definition['rootPath'] . '/' . + strtolower( $theme ) . '/' . + $this->definition['name'] . '.json'; + } + + return $this->readJSONFile( $dataPath ); + } + + /** + * Read JSON from a file, and transform all paths in it to be relative to the module's base path. + * + * @since 1.34 + * @param string $dataPath Path relative to the module's base bath + * @return array|false + */ + protected function readJSONFile( $dataPath ) { + $localDataPath = $this->localBasePath . '/' . $dataPath; + + if ( !file_exists( $localDataPath ) ) { + return false; + } + + $data = json_decode( file_get_contents( $localDataPath ), true ); + + // Expand the paths to images (since they are relative to the JSON file that defines them, not + // our base directory) + $fixPath = function ( &$path ) use ( $dataPath ) { + $path = dirname( $dataPath ) . '/' . $path; + }; + array_walk( $data['images'], function ( &$value ) use ( $fixPath ) { + if ( is_string( $value['file'] ) ) { + $fixPath( $value['file'] ); + } elseif ( is_array( $value['file'] ) ) { + array_walk_recursive( $value['file'], $fixPath ); + } + } ); + + return $data; + } } diff --git a/includes/resourceloader/ResourceLoaderOOUIModule.php b/includes/resourceloader/ResourceLoaderOOUIModule.php index 0395127c57..899fbbde72 100644 --- a/includes/resourceloader/ResourceLoaderOOUIModule.php +++ b/includes/resourceloader/ResourceLoaderOOUIModule.php @@ -27,7 +27,7 @@ trait ResourceLoaderOOUIModule { protected static $knownScriptsModules = [ 'core' ]; protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ]; protected static $knownImagesModules = [ - 'indicators', 'textures', + 'indicators', // Extra icons 'icons-accessibility', 'icons-alerts', diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php index c834db10e2..7880f6f249 100644 --- a/includes/resourceloader/ResourceLoaderStartUpModule.php +++ b/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -70,14 +70,13 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // Build list of variables $skin = $context->getSkin(); $vars = [ - 'wgLoadScript' => wfScript( 'load' ), 'debug' => $context->getDebug(), 'skin' => $skin, 'stylepath' => $conf->get( 'StylePath' ), 'wgUrlProtocols' => wfUrlProtocols(), 'wgArticlePath' => $conf->get( 'ArticlePath' ), 'wgScriptPath' => $conf->get( 'ScriptPath' ), - 'wgScript' => wfScript(), + 'wgScript' => $conf->get( 'Script' ), 'wgSearchType' => $conf->get( 'SearchType' ), 'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ), // Force object to avoid "empty" associative array from @@ -106,7 +105,6 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces, 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ), - 'wgResourceLoaderStorageVersion' => $conf->get( 'ResourceLoaderStorageVersion' ), 'wgResourceLoaderStorageEnabled' => $conf->get( 'ResourceLoaderStorageEnabled' ), 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ), 'wgEnableUploads' => $conf->get( 'EnableUploads' ), @@ -124,29 +122,50 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { * * @param array $registryData * @param string $moduleName + * @param string[] $handled Internal parameter for recursion. (Optional) * @return array + * @throws ResourceLoaderCircularDependencyError */ - protected static function getImplicitDependencies( array $registryData, $moduleName ) { + protected static function getImplicitDependencies( + array $registryData, + $moduleName, + array $handled = [] + ) { static $dependencyCache = []; - // The list of implicit dependencies won't be altered, so we can - // cache them without having to worry. + // No modules will be added or changed server-side after this point, + // so we can safely cache parts of the tree for re-use. if ( !isset( $dependencyCache[$moduleName] ) ) { if ( !isset( $registryData[$moduleName] ) ) { - // Dependencies may not exist - $dependencyCache[$moduleName] = []; + // Unknown module names are allowed here, this is only an optimisation. + // Checks for illegal and unknown dependencies happen as PHPUnit structure tests, + // and also client-side at run-time. + $flat = []; } else { $data = $registryData[$moduleName]; - $dependencyCache[$moduleName] = $data['dependencies']; + $flat = $data['dependencies']; + // Prevent recursion + $handled[] = $moduleName; foreach ( $data['dependencies'] as $dependency ) { - // Recursively get the dependencies of the dependencies - $dependencyCache[$moduleName] = array_merge( - $dependencyCache[$moduleName], - self::getImplicitDependencies( $registryData, $dependency ) - ); + if ( in_array( $dependency, $handled, true ) ) { + // If we encounter a circular dependency, then stop the optimiser and leave the + // original dependencies array unmodified. Circular dependencies are not + // supported in ResourceLoader. Awareness of them exists here so that we can + // optimise the registry when it isn't broken, and otherwise transport the + // registry unchanged. The client will handle this further. + throw new ResourceLoaderCircularDependencyError(); + } else { + // Recursively add the dependencies of the dependencies + $flat = array_merge( + $flat, + self::getImplicitDependencies( $registryData, $dependency, $handled ) + ); + } } } + + $dependencyCache[$moduleName] = $flat; } return $dependencyCache[$moduleName]; @@ -173,10 +192,16 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { public static function compileUnresolvedDependencies( array &$registryData ) { foreach ( $registryData as $name => &$data ) { $dependencies = $data['dependencies']; - foreach ( $data['dependencies'] as $dependency ) { - $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); - $dependencies = array_diff( $dependencies, $implicitDependencies ); + try { + foreach ( $data['dependencies'] as $dependency ) { + $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); + $dependencies = array_diff( $dependencies, $implicitDependencies ); + } + } catch ( ResourceLoaderCircularDependencyError $err ) { + // Leave unchanged + $dependencies = $data['dependencies']; } + // Rebuild keys $data['dependencies'] = array_values( $dependencies ); } @@ -227,10 +252,11 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { continue; } - if ( $module->isRaw() ) { - // Don't register "raw" modules (like 'startup') client-side because depending on them - // is illegal anyway and would only lead to them being loaded a second time, - // causing any state to be lost. + if ( $module instanceof ResourceLoaderStartUpModule ) { + // Don't register 'startup' to the client because loading it lazily or depending + // on it doesn't make sense, because the startup module *is* the client. + // Registering would be a waste of bandwidth and memory and risks somehow causing + // it to load a second time. // ATTENTION: Because of the line below, this is not going to cause infinite recursion. // Think carefully before making changes to this code! @@ -258,7 +284,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { } if ( $versionHash !== '' && strlen( $versionHash ) !== 7 ) { - $context->getLogger()->warning( + $this->getLogger()->warning( "Module '{module}' produced an invalid version hash: '{version}'.", [ 'module' => $name, @@ -314,17 +340,11 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { return $out; } - /** - * @return bool - */ - public function isRaw() { - return true; - } - /** * @private For internal use by SpecialJavaScriptTest * @since 1.32 * @return array + * @codeCoverageIgnore */ public function getBaseModulesInternal() { return $this->getBaseModules(); @@ -346,6 +366,30 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { return $baseModules; } + /** + * Get the localStorage key for the entire module store. The key references + * $wgDBname to prevent clashes between wikis under the same web domain. + * + * @return string localStorage item key for JavaScript + */ + private function getStoreKey() { + return 'MediaWikiModuleStore:' . $this->getConfig()->get( 'DBname' ); + } + + /** + * Get the key on which the JavaScript module cache (mw.loader.store) will vary. + * + * @param ResourceLoaderContext $context + * @return string String of concatenated vary conditions + */ + private function getStoreVary( ResourceLoaderContext $context ) { + return implode( ':', [ + $context->getSkin(), + $this->getConfig()->get( 'ResourceLoaderStorageVersion' ), + $context->getLanguage(), + ] ); + } + /** * @param ResourceLoaderContext $context * @return string JavaScript code @@ -373,10 +417,13 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // Perform replacements for mediawiki.js $mwLoaderPairs = [ + '$VARS.reqBase' => ResourceLoader::encodeJsonForScript( $context->getReqBase() ), '$VARS.baseModules' => ResourceLoader::encodeJsonForScript( $this->getBaseModules() ), '$VARS.maxQueryLength' => ResourceLoader::encodeJsonForScript( $conf->get( 'ResourceLoaderMaxQueryLength' ) ), + '$VARS.storeKey' => ResourceLoader::encodeJsonForScript( $this->getStoreKey() ), + '$VARS.storeVary' => ResourceLoader::encodeJsonForScript( $this->getStoreVary( $context ) ), ]; $profilerStubs = [ '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );', @@ -425,11 +472,4 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { // and hash it to determine the version (as used by E-Tag HTTP response header). return true; } - - /** - * @return string - */ - public function getGroup() { - return 'startup'; - } } diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index 4c11fce0b1..d37c31b1f5 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -484,7 +484,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $allInfo = $cache->getWithSetCallback( - $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID(), $hash ), + $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ), $cache::TTL_HOUR, function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) { $setOpts += Database::getCacheSetOptions( $db ); @@ -493,7 +493,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { }, [ 'checkKeys' => [ - $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID() ) ] + $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID() ) ] ] ); @@ -550,7 +550,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( $purge ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - $key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $domain ); + $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain ); $cache->touchCheckKey( $key ); } } diff --git a/includes/revisiondelete/RevDelFileList.php b/includes/revisiondelete/RevDelFileList.php index 6a6b86c099..ca7bc040d0 100644 --- a/includes/revisiondelete/RevDelFileList.php +++ b/includes/revisiondelete/RevDelFileList.php @@ -19,6 +19,7 @@ * @ingroup RevisionDelete */ +use MediaWiki\MediaWikiServices; use Wikimedia\Rdbms\IDatabase; /** @@ -109,7 +110,8 @@ class RevDelFileList extends RevDelList { } public function doPostCommitUpdates( array $visibilityChangeMap ) { - $file = wfLocalFile( $this->title ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo() + ->newFile( $this->title ); $file->purgeCache(); $file->purgeDescription(); diff --git a/includes/search/PrefixSearch.php b/includes/search/PrefixSearch.php index 3b7a0a9f99..3fff6c1b60 100644 --- a/includes/search/PrefixSearch.php +++ b/includes/search/PrefixSearch.php @@ -30,22 +30,6 @@ use MediaWiki\MediaWikiServices; * @ingroup Search */ abstract class PrefixSearch { - /** - * Do a prefix search of titles and return a list of matching page names. - * @deprecated Since 1.23, use TitlePrefixSearch or StringPrefixSearch classes - * - * @param string $search - * @param int $limit - * @param array $namespaces Used if query is not explicitly prefixed - * @param int $offset How many results to offset from the beginning - * @return array Array of strings - */ - public static function titleSearch( $search, $limit, $namespaces = [], $offset = 0 ) { - wfDeprecated( __METHOD__, '1.23' ); - $prefixSearch = new StringPrefixSearch; - return $prefixSearch->search( $search, $limit, $namespaces, $offset ); - } - /** * Do a prefix search of titles and return a list of matching page names. * diff --git a/includes/search/SearchDatabase.php b/includes/search/SearchDatabase.php index 230cdedd71..8ea356f77a 100644 --- a/includes/search/SearchDatabase.php +++ b/includes/search/SearchDatabase.php @@ -22,6 +22,7 @@ */ use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\ILoadBalancer; /** * Base search engine base class for database-backed searches @@ -29,16 +30,23 @@ use Wikimedia\Rdbms\IDatabase; * @since 1.23 */ abstract class SearchDatabase extends SearchEngine { + /** @var ILoadBalancer */ + protected $lb; + /** @var IDatabase (backwards compatibility) */ + protected $db; + /** - * @var IDatabase Replica database from which to read results + * @var string[] search terms */ - protected $db; + protected $searchTerms = []; /** - * @param IDatabase|null $db The database to search from + * @param ILoadBalancer $lb The load balancer for the DB cluster to search on */ - public function __construct( IDatabase $db = null ) { - $this->db = $db ?: wfGetDB( DB_REPLICA ); + public function __construct( ILoadBalancer $lb ) { + $this->lb = $lb; + // @TODO: remove this deprecated field in 1.35 + $this->db = $lb->getLazyConnectionRef( DB_REPLICA ); // b/c } /** diff --git a/includes/search/SearchEngine.php b/includes/search/SearchEngine.php index b99c0d3dee..2fb4585c92 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -46,7 +46,10 @@ abstract class SearchEngine { /** @var int */ protected $offset = 0; - /** @var array|string */ + /** + * @var string[] + * @deprecated since 1.34 + */ protected $searchTerms = []; /** @var bool */ @@ -106,7 +109,7 @@ abstract class SearchEngine { * be converted to final in 1.34. Override self::doSearchArchiveTitle(). * * @param string $term Raw search term - * @return Status + * @return Status * @since 1.29 */ public function searchArchiveTitle( $term ) { @@ -117,7 +120,7 @@ abstract class SearchEngine { * Perform a title search in the article archive. * * @param string $term Raw search term - * @return Status + * @return Status * @since 1.32 */ protected function doSearchArchiveTitle( $term ) { @@ -235,20 +238,6 @@ abstract class SearchEngine { return MediaWikiServices::getInstance()->getContentLanguage()->segmentByWord( $string ); } - /** - * Transform search term in cases when parts of the query came as different - * GET params (when supported), e.g. for prefix queries: - * search=test&prefix=Main_Page/Archive -> test prefix:Main Page/Archive - * @param string $term - * @return string - * @deprecated since 1.32 this should now be handled internally by the - * search engine - */ - public function transformSearchTerm( $term ) { - wfDeprecated( __METHOD__, '1.32' ); - return $term; - } - /** * Get service class to finding near matches. * @param Config $config Configuration to use for the matcher. diff --git a/includes/search/SearchEngineFactory.php b/includes/search/SearchEngineFactory.php index ecb6f43e64..6a69cd4ee9 100644 --- a/includes/search/SearchEngineFactory.php +++ b/includes/search/SearchEngineFactory.php @@ -1,6 +1,8 @@ config->getSearchType(); + $alternativesClasses = $this->config->getSearchTypes(); - $configType = $this->config->getSearchType(); - $alternatives = $this->config->getSearchTypes(); - - if ( $type && in_array( $type, $alternatives ) ) { + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + if ( $type !== null && in_array( $type, $alternativesClasses ) ) { $class = $type; - } elseif ( $configType !== null ) { - $class = $configType; + } elseif ( $configuredClass !== null ) { + $class = $configuredClass; } else { - $dbr = wfGetDB( DB_REPLICA ); - $class = self::getSearchEngineClass( $dbr ); + $class = self::getSearchEngineClass( $lb ); } - $search = new $class( $dbr ); - return $search; + if ( is_subclass_of( $class, SearchDatabase::class ) ) { + return new $class( $lb ); + } else { + return new $class(); + } } /** - * @param IDatabase $db + * @param IDatabase|ILoadBalancer $dbOrLb * @return string SearchEngine subclass name * @since 1.28 */ - public static function getSearchEngineClass( IDatabase $db ) { - switch ( $db->getType() ) { + public static function getSearchEngineClass( $dbOrLb ) { + $type = ( $dbOrLb instanceof IDatabase ) + ? $dbOrLb->getType() + : $dbOrLb->getServerType( $dbOrLb->getWriterIndex() ); + + switch ( $type ) { case 'sqlite': return SearchSqlite::class; case 'mysql': diff --git a/includes/search/SearchHighlighter.php b/includes/search/SearchHighlighter.php index 469502fd43..6c01f799d5 100644 --- a/includes/search/SearchHighlighter.php +++ b/includes/search/SearchHighlighter.php @@ -44,7 +44,7 @@ class SearchHighlighter { * Wikitext highlighting when $wgAdvancedSearchHighlighting = true * * @param string $text - * @param array $terms Terms to highlight (not html escaped but + * @param string[] $terms Terms to highlight (not html escaped but * regex escaped via SearchDatabase::regexTerm()) * @param int $contextlines * @param int $contextchars @@ -502,7 +502,7 @@ class SearchHighlighter { * Used when $wgAdvancedSearchHighlighting is false. * * @param string $text - * @param array $terms Escaped for regex by SearchDatabase::regexTerm() + * @param string[] $terms Escaped for regex by SearchDatabase::regexTerm() * @param int $contextlines * @param int $contextchars * @return string diff --git a/includes/search/SearchMssql.php b/includes/search/SearchMssql.php index 0e85f9df2f..6a23bb344f 100644 --- a/includes/search/SearchMssql.php +++ b/includes/search/SearchMssql.php @@ -36,7 +36,9 @@ class SearchMssql extends SearchDatabase { * @return SqlSearchResultSet */ protected function doSearchTextInDB( $term ) { - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), true ) ); + return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -47,7 +49,9 @@ class SearchMssql extends SearchDatabase { * @return SqlSearchResultSet */ protected function doSearchTitleInDB( $term ) { - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), false ) ); + return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -72,7 +76,9 @@ class SearchMssql extends SearchDatabase { * @return string */ private function queryLimit( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -120,8 +126,9 @@ class SearchMssql extends SearchDatabase { */ private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return 'SELECT page_id, page_namespace, page_title, ftindex.[RANK]' . "FROM $page,FREETEXTTABLE($searchindex , $match, LANGUAGE 'English') as ftindex " . @@ -159,8 +166,10 @@ class SearchMssql extends SearchDatabase { } } - $searchon = $this->db->addQuotes( implode( ',', $q ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( implode( ',', $q ) ); $field = $this->getIndexField( $fulltext ); + return "$field, $searchon"; } @@ -179,13 +188,14 @@ class SearchMssql extends SearchDatabase { // to properly decode the stream as UTF-8. SQL doesn't support UTF8 as a data type // but the indexer will correctly handle it by this method. Since all we are doing // is passing this data to the indexer and never retrieving it via PHP, this will save space - $table = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_MASTER ); + $table = $dbr->tableName( 'searchindex' ); $utf8bom = '0xEFBBBF'; $si_title = $utf8bom . bin2hex( $title ); $si_text = $utf8bom . bin2hex( $text ); $sql = "DELETE FROM $table WHERE si_page = $id;"; $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, $si_text)"; - return $this->db->query( $sql, 'SearchMssql::update' ); + return $dbr->query( $sql, 'SearchMssql::update' ); } /** @@ -197,13 +207,14 @@ class SearchMssql extends SearchDatabase { * @return bool|IResultWrapper */ function updateTitle( $id, $title ) { - $table = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_MASTER ); + $table = $dbr->tableName( 'searchindex' ); // see update for why we are using the utf8bom $utf8bom = '0xEFBBBF'; $si_title = $utf8bom . bin2hex( $title ); $sql = "DELETE FROM $table WHERE si_page = $id;"; $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, 0x00)"; - return $this->db->query( $sql, 'SearchMssql::updateTitle' ); + return $dbr->query( $sql, 'SearchMssql::updateTitle' ); } } diff --git a/includes/search/SearchMySQL.php b/includes/search/SearchMySQL.php index cae342670e..4a6b93b209 100644 --- a/includes/search/SearchMySQL.php +++ b/includes/search/SearchMySQL.php @@ -124,7 +124,8 @@ class SearchMySQL extends SearchDatabase { wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" ); } - $searchon = $this->db->addQuotes( $searchon ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( $searchon ); $field = $this->getIndexField( $fulltext ); return [ " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ", @@ -186,14 +187,15 @@ class SearchMySQL extends SearchDatabase { $filteredTerm = $this->filter( $term ); $query = $this->getQuery( $filteredTerm, $fulltext ); - $resultSet = $this->db->select( + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->select( $query['tables'], $query['fields'], $query['conds'], __METHOD__, $query['options'], $query['joins'] ); $total = null; $query = $this->getCountQuery( $filteredTerm, $fulltext ); - $totalResult = $this->db->select( + $totalResult = $dbr->select( $query['tables'], $query['fields'], $query['conds'], __METHOD__, $query['options'], $query['joins'] ); @@ -224,7 +226,8 @@ class SearchMySQL extends SearchDatabase { protected function queryFeatures( &$query ) { foreach ( $this->features as $feature => $value ) { if ( $feature === 'title-suffix-filter' && $value ) { - $query['conds'][] = 'page_title' . $this->db->buildLike( $this->db->anyString(), $value ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $query['conds'][] = 'page_title' . $dbr->buildLike( $dbr->anyString(), $value ); } } } @@ -339,7 +342,7 @@ class SearchMySQL extends SearchDatabase { * @param string $text */ function update( $id, $title, $text ) { - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->replace( 'searchindex', [ 'si_page' ], [ @@ -357,13 +360,12 @@ class SearchMySQL extends SearchDatabase { * @param string $title */ function updateTitle( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $this->normalizeText( $title ) ], [ 'si_page' => $id ], - __METHOD__, - [ $dbw->lowPriorityOption() ] ); + __METHOD__ + ); } /** @@ -374,8 +376,7 @@ class SearchMySQL extends SearchDatabase { * @param string $title Title of page that was deleted */ function delete( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->delete( 'searchindex', [ 'si_page' => $id ], __METHOD__ ); } @@ -441,7 +442,7 @@ class SearchMySQL extends SearchDatabase { if ( is_null( self::$mMinSearchLength ) ) { $sql = "SHOW GLOBAL VARIABLES LIKE 'ft\\_min\\_word\\_len'"; - $dbr = wfGetDB( DB_REPLICA ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); $result = $dbr->query( $sql, __METHOD__ ); $row = $result->fetchObject(); $result->free(); diff --git a/includes/search/SearchNearMatcher.php b/includes/search/SearchNearMatcher.php index 9ee3e17586..d400267710 100644 --- a/includes/search/SearchNearMatcher.php +++ b/includes/search/SearchNearMatcher.php @@ -1,5 +1,7 @@ getNamespace() == NS_FILE ) { - $image = wfFindFile( $title ); + $image = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); if ( $image ) { return $title; } diff --git a/includes/search/SearchOracle.php b/includes/search/SearchOracle.php index 6b2b4038dc..a5d351bcd7 100644 --- a/includes/search/SearchOracle.php +++ b/includes/search/SearchOracle.php @@ -71,7 +71,8 @@ class SearchOracle extends SearchDatabase { return new SqlSearchResultSet( false, '' ); } - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), true ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -86,7 +87,8 @@ class SearchOracle extends SearchDatabase { return new SqlSearchResultSet( false, '' ); } - $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $this->filter( $term ), false ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -101,7 +103,8 @@ class SearchOracle extends SearchDatabase { if ( $this->namespaces === [] ) { $namespaces = '0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $namespaces = $dbr->makeList( $this->namespaces ); } return 'AND page_namespace IN (' . $namespaces . ')'; } @@ -114,7 +117,9 @@ class SearchOracle extends SearchDatabase { * @return string */ private function queryLimit( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -160,8 +165,11 @@ class SearchOracle extends SearchDatabase { */ function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); + return 'SELECT page_id, page_namespace, page_title ' . "FROM $page,$searchindex " . 'WHERE page_id=si_page AND ' . $match; @@ -208,8 +216,10 @@ class SearchOracle extends SearchDatabase { } } - $searchon = $this->db->addQuotes( ltrim( $searchon, ' &' ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( ltrim( $searchon, ' &' ) ); $field = $this->getIndexField( $fulltext ); + return " CONTAINS($field, $searchon, 1) > 0 "; } @@ -230,7 +240,7 @@ class SearchOracle extends SearchDatabase { * @param string $text */ function update( $id, $title, $text ) { - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getConnection( DB_MASTER ); $dbw->replace( 'searchindex', [ 'si_page' ], [ @@ -258,8 +268,7 @@ class SearchOracle extends SearchDatabase { * @param string $title */ function updateTitle( $id, $title ) { - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $title ], [ 'si_page' => $id ], diff --git a/includes/search/SearchPostgres.php b/includes/search/SearchPostgres.php index 74ee552abb..63634cba7f 100644 --- a/includes/search/SearchPostgres.php +++ b/includes/search/SearchPostgres.php @@ -42,7 +42,8 @@ class SearchPostgres extends SearchDatabase { protected function doSearchTitleInDB( $term ) { $q = $this->searchQuery( $term, 'titlevector', 'page_title' ); $olderror = error_reporting( E_ERROR ); - $resultSet = $this->db->query( $q, 'SearchPostgres', true ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $q, 'SearchPostgres', true ); error_reporting( $olderror ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -50,7 +51,8 @@ class SearchPostgres extends SearchDatabase { protected function doSearchTextInDB( $term ) { $q = $this->searchQuery( $term, 'textvector', 'old_text' ); $olderror = error_reporting( E_ERROR ); - $resultSet = $this->db->query( $q, 'SearchPostgres', true ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $q, 'SearchPostgres', true ); error_reporting( $olderror ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -111,7 +113,8 @@ class SearchPostgres extends SearchDatabase { $searchstring = preg_replace( '/^[\'"](.*)[\'"]$/', "$1", $searchstring ); # # Quote the whole thing - $searchstring = $this->db->addQuotes( $searchstring ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchstring = $dbr->addQuotes( $searchstring ); wfDebug( "parseQuery returned: $searchstring \n" ); @@ -131,7 +134,8 @@ class SearchPostgres extends SearchDatabase { # # We need a separate query here so gin does not complain about empty searches $sql = "SELECT to_tsquery($searchstring)"; - $res = $this->db->query( $sql ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $res = $dbr->query( $sql ); if ( !$res ) { # # TODO: Better output (example to catch: one 'two) die( "Sorry, that was not a valid search string. Please go back and try again" ); @@ -172,14 +176,14 @@ class SearchPostgres extends SearchDatabase { if ( count( $this->namespaces ) < 1 ) { $query .= ' AND page_namespace = 0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $namespaces = $dbr->makeList( $this->namespaces ); $query .= " AND page_namespace IN ($namespaces)"; } } $query .= " ORDER BY score DESC, page_id DESC"; - $query .= $this->db->limitResult( '', $this->limit, $this->offset ); + $query .= $dbr->limitResult( '', $this->limit, $this->offset ); wfDebug( "searchQuery returned: $query \n" ); @@ -201,12 +205,14 @@ class SearchPostgres extends SearchDatabase { " AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " . " AND c.content_id = s.slot_content_id " . " ORDER BY old_rev_text_id DESC OFFSET 1)"; - $this->db->query( $sql ); + + $dbw = $this->lb->getConnectionRef( DB_MASTER ); + $dbw->query( $sql ); + return true; } function updateTitle( $id, $title ) { return true; } - } diff --git a/includes/search/SearchResult.php b/includes/search/SearchResult.php index bd19a84e32..7703e38dd0 100644 --- a/includes/search/SearchResult.php +++ b/includes/search/SearchResult.php @@ -86,16 +86,17 @@ class SearchResult { */ protected function initFromTitle( $title ) { $this->mTitle = $title; + $services = MediaWikiServices::getInstance(); if ( !is_null( $this->mTitle ) ) { $id = false; Hooks::run( 'SearchResultInitFromTitle', [ $title, &$id ] ); $this->mRevision = Revision::newFromTitle( $this->mTitle, $id, Revision::READ_NORMAL ); if ( $this->mTitle->getNamespace() === NS_FILE ) { - $this->mImage = wfFindFile( $this->mTitle ); + $this->mImage = $services->getRepoGroup()->findFile( $this->mTitle ); } } - $this->searchEngine = MediaWikiServices::getInstance()->newSearchEngine(); + $this->searchEngine = $services->newSearchEngine(); } /** @@ -146,26 +147,11 @@ class SearchResult { } /** - * @param array $terms Terms to highlight + * @param string[] $terms Terms to highlight (this parameter is deprecated and ignored) * @return string Highlighted text snippet, null (and not '') if not supported */ - function getTextSnippet( $terms ) { - global $wgAdvancedSearchHighlighting; - $this->initText(); - - // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter. - list( $contextlines, $contextchars ) = $this->searchEngine->userHighlightPrefs(); - - $h = new SearchHighlighter(); - if ( count( $terms ) > 0 ) { - if ( $wgAdvancedSearchHighlighting ) { - return $h->highlightText( $this->mText, $terms, $contextlines, $contextchars ); - } else { - return $h->highlightSimple( $this->mText, $terms, $contextlines, $contextchars ); - } - } else { - return $h->highlightNone( $this->mText, $contextlines, $contextchars ); - } + function getTextSnippet( $terms = [] ) { + return ''; } /** @@ -285,7 +271,7 @@ class SearchResult { if ( $extensionData instanceof Closure ) { $this->extensionData = $extensionData; } elseif ( is_array( $extensionData ) ) { - wfDeprecated( __METHOD__ . ' with array argument', 1.32 ); + wfDeprecated( __METHOD__ . ' with array argument', '1.32' ); $this->extensionData = function () use ( $extensionData ) { return $extensionData; }; diff --git a/includes/search/SearchResultSet.php b/includes/search/SearchResultSet.php index 18331ddc75..5ee96cba65 100644 --- a/includes/search/SearchResultSet.php +++ b/includes/search/SearchResultSet.php @@ -84,7 +84,7 @@ class SearchResultSet implements Countable, IteratorAggregate { // This class will eventually be abstract. SearchEngine implementations // already have to extend this class anyways to provide the actual // search results. - wfDeprecated( __METHOD__, 1.32 ); + wfDeprecated( __METHOD__, '1.32' ); } $this->containedSyntax = $containedSyntax; $this->hasMoreResults = $hasMoreResults; @@ -95,7 +95,8 @@ class SearchResultSet implements Countable, IteratorAggregate { * the search terms as parsed by this engine in a text extract. * STUB * - * @return array + * @return string[] + * @deprecated since 1.34 (use SqlSearchResult) */ function termMatches() { return []; diff --git a/includes/search/SearchSqlite.php b/includes/search/SearchSqlite.php index c30479766e..3646b274ed 100644 --- a/includes/search/SearchSqlite.php +++ b/includes/search/SearchSqlite.php @@ -22,6 +22,7 @@ */ use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\DatabaseSqlite; /** * Search engine hook for SQLite @@ -33,7 +34,10 @@ class SearchSqlite extends SearchDatabase { * @return bool */ function fulltextSearchSupported() { - return $this->db->checkForEnabledSearch(); + /** @var DatabaseSqlite $dbr */ + $dbr = $this->lb->getConnection( DB_REPLICA ); + + return $dbr->checkForEnabledSearch(); } /** @@ -120,8 +124,10 @@ class SearchSqlite extends SearchDatabase { wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" ); } - $searchon = $this->db->addQuotes( $searchon ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $searchon = $dbr->addQuotes( $searchon ); $field = $this->getIndexField( $fulltext ); + return " $field MATCH $searchon "; } @@ -178,10 +184,11 @@ class SearchSqlite extends SearchDatabase { $filteredTerm = $this->filter( MediaWikiServices::getInstance()->getContentLanguage()->lc( $term ) ); - $resultSet = $this->db->query( $this->getQuery( $filteredTerm, $fulltext ) ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $resultSet = $dbr->query( $this->getQuery( $filteredTerm, $fulltext ) ); $total = null; - $totalResult = $this->db->query( $this->getCountQuery( $filteredTerm, $fulltext ) ); + $totalResult = $dbr->query( $this->getCountQuery( $filteredTerm, $fulltext ) ); $row = $totalResult->fetchObject(); if ( $row ) { $total = intval( $row->c ); @@ -202,7 +209,8 @@ class SearchSqlite extends SearchDatabase { if ( $this->namespaces === [] ) { $namespaces = '0'; } else { - $namespaces = $this->db->makeList( $this->namespaces ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + $namespaces = $dbr->makeList( $this->namespaces ); } return 'AND page_namespace IN (' . $namespaces . ')'; } @@ -213,7 +221,9 @@ class SearchSqlite extends SearchDatabase { * @return string */ private function limitResult( $sql ) { - return $this->db->limitResult( $sql, $this->limit, $this->offset ); + $dbr = $this->lb->getConnectionRef( DB_REPLICA ); + + return $dbr->limitResult( $sql, $this->limit, $this->offset ); } /** @@ -248,8 +258,9 @@ class SearchSqlite extends SearchDatabase { */ private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return "SELECT $searchindex.rowid, page_namespace, page_title " . "FROM $page,$searchindex " . "WHERE page_id=$searchindex.rowid AND $match"; @@ -257,8 +268,9 @@ class SearchSqlite extends SearchDatabase { private function getCountQuery( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); - $page = $this->db->tableName( 'page' ); - $searchindex = $this->db->tableName( 'searchindex' ); + $dbr = $this->lb->getMaintenanceConnectionRef( DB_REPLICA ); + $page = $dbr->tableName( 'page' ); + $searchindex = $dbr->tableName( 'searchindex' ); return "SELECT COUNT(*) AS c " . "FROM $page,$searchindex " . "WHERE page_id=$searchindex.rowid AND $match " . @@ -279,10 +291,8 @@ class SearchSqlite extends SearchDatabase { } // @todo find a method to do it in a single request, // couldn't do it so far due to typelessness of FTS3 tables. - $dbw = wfGetDB( DB_MASTER ); - + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->delete( 'searchindex', [ 'rowid' => $id ], __METHOD__ ); - $dbw->insert( 'searchindex', [ 'rowid' => $id, @@ -302,8 +312,8 @@ class SearchSqlite extends SearchDatabase { if ( !$this->fulltextSearchSupported() ) { return; } - $dbw = wfGetDB( DB_MASTER ); + $dbw = $this->lb->getConnectionRef( DB_MASTER ); $dbw->update( 'searchindex', [ 'si_title' => $title ], [ 'rowid' => $id ], diff --git a/includes/search/SqlSearchResult.php b/includes/search/SqlSearchResult.php new file mode 100644 index 0000000000..25e87e70ff --- /dev/null +++ b/includes/search/SqlSearchResult.php @@ -0,0 +1,69 @@ +initFromTitle( $title ); + $this->terms = $terms; + } + + /** + * return string[] + */ + public function getTermMatches(): array { + return $this->terms; + } + + /** + * @param array $terms Terms to highlight (this parameter is deprecated) + * @return string Highlighted text snippet, null (and not '') if not supported + */ + function getTextSnippet( $terms = [] ) { + global $wgAdvancedSearchHighlighting; + $this->initText(); + + // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter. + list( $contextlines, $contextchars ) = $this->searchEngine->userHighlightPrefs(); + + $h = new SearchHighlighter(); + if ( count( $this->terms ) > 0 ) { + if ( $wgAdvancedSearchHighlighting ) { + return $h->highlightText( $this->mText, $this->terms, $contextlines, $contextchars ); + } else { + return $h->highlightSimple( $this->mText, $this->terms, $contextlines, $contextchars ); + } + } else { + return $h->highlightNone( $this->mText, $contextlines, $contextchars ); + } + } + +} diff --git a/includes/search/SqlSearchResultSet.php b/includes/search/SqlSearchResultSet.php index 022dc0a643..87068ca3d3 100644 --- a/includes/search/SqlSearchResultSet.php +++ b/includes/search/SqlSearchResultSet.php @@ -1,25 +1,37 @@ resultSet = $resultSet; $this->terms = $terms; $this->totalHits = $total; } + /** + * @return string[] + * @deprecated since 1.34 + */ function termMatches() { return $this->terms; } @@ -40,10 +52,15 @@ class SqlSearchResultSet extends SearchResultSet { if ( $this->results === null ) { $this->results = []; $this->resultSet->rewind(); + $terms = \MediaWiki\MediaWikiServices::getInstance()->getContentLanguage() + ->convertForSearchResult( $this->terms ); while ( ( $row = $this->resultSet->fetchObject() ) !== false ) { - $this->results[] = SearchResult::newFromTitle( - Title::makeTitle( $row->page_namespace, $row->page_title ), $this + $result = new SqlSearchResult( + Title::makeTitle( $row->page_namespace, $row->page_title ), + $terms ); + $this->augmentResult( $result ); + $this->results[] = $result; } } return $this->results; @@ -51,7 +68,7 @@ class SqlSearchResultSet extends SearchResultSet { function free() { if ( $this->resultSet === false ) { - return false; + return; } $this->resultSet->free(); diff --git a/includes/session/SessionBackend.php b/includes/session/SessionBackend.php index 0ea13e2a91..3f12563adb 100644 --- a/includes/session/SessionBackend.php +++ b/includes/session/SessionBackend.php @@ -27,6 +27,7 @@ use CachedBagOStuff; use Psr\Log\LoggerInterface; use User; use WebRequest; +use Wikimedia\AtEase\AtEase; /** * This is the actual workhorse for Session. @@ -262,7 +263,7 @@ final class SessionBackend { if ( $restart ) { session_id( (string)$this->id ); - \Wikimedia\quietCall( 'session_start' ); + AtEase::quietCall( 'session_start' ); } $this->autosave(); @@ -785,7 +786,7 @@ final class SessionBackend { 'session' => $this->id, ] ); session_id( (string)$this->id ); - \Wikimedia\quietCall( 'session_start' ); + AtEase::quietCall( 'session_start' ); } } } diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php index 98c04995a3..3810565bcb 100644 --- a/includes/session/SessionManager.php +++ b/includes/session/SessionManager.php @@ -329,12 +329,9 @@ final class SessionManager implements SessionManagerInterface { $headers = []; foreach ( $this->getProviders() as $provider ) { foreach ( $provider->getVaryHeaders() as $header => $options ) { - if ( !isset( $headers[$header] ) ) { - $headers[$header] = []; - } - if ( is_array( $options ) ) { - $headers[$header] = array_unique( array_merge( $headers[$header], $options ) ); - } + # Note that the $options value returned has been deprecated + # and is ignored. + $headers[$header] = null; } } $this->varyHeaders = $headers; diff --git a/includes/session/SessionManagerInterface.php b/includes/session/SessionManagerInterface.php index c6990fefe7..7c05cfc6a6 100644 --- a/includes/session/SessionManagerInterface.php +++ b/includes/session/SessionManagerInterface.php @@ -96,6 +96,9 @@ interface SessionManagerInterface extends LoggerAwareInterface { * } * @endcode * + * Note that the $options argument to OutputPage::addVaryHeader() has + * been deprecated and should always be null. + * * @return array */ public function getVaryHeaders(); diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php index def3bc3125..80a400b057 100644 --- a/includes/session/SessionProvider.php +++ b/includes/session/SessionProvider.php @@ -402,6 +402,9 @@ abstract class SessionProvider implements SessionProviderInterface, LoggerAwareI * } * @endcode * + * Note that the $options parameter to addVaryHeader has been deprecated + * since 1.34, and should be `null` or an empty array. + * * @protected For use by \MediaWiki\Session\SessionManager only * @return array */ diff --git a/includes/shell/Command.php b/includes/shell/Command.php index 109097ae86..7742075e0f 100644 --- a/includes/shell/Command.php +++ b/includes/shell/Command.php @@ -26,6 +26,7 @@ use MediaWiki\ShellDisabledError; use Profiler; use Psr\Log\LoggerAwareTrait; use Psr\Log\NullLogger; +use Wikimedia\AtEase\AtEase; /** * Class used for executing shell commands @@ -72,14 +73,14 @@ class Command { private $cgroup = false; /** - * bitfield with restrictions + * Bitfield with restrictions * * @var int */ protected $restrictions = 0; /** - * Constructor. Don't call directly, instead use Shell::command() + * Don't call directly, instead use Shell::command() * * @throws ShellDisabledError */ @@ -92,7 +93,7 @@ class Command { } /** - * Destructor. Makes sure programmer didn't forget to execute the command after all + * Makes sure the programmer didn't forget to execute the command after all */ public function __destruct() { if ( !$this->everExecuted ) { @@ -431,9 +432,9 @@ class Command { // TODO replace with clear_last_error when requirements are bumped to PHP7 set_error_handler( function () { }, 0 ); - \Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); trigger_error( '' ); - \Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); restore_error_handler(); $readPipes = array_filter( $pipes, function ( $fd ) use ( $desc ) { diff --git a/includes/site/DBSiteStore.php b/includes/site/DBSiteStore.php index b2403ce16b..bb6a6b3cb0 100644 --- a/includes/site/DBSiteStore.php +++ b/includes/site/DBSiteStore.php @@ -1,6 +1,6 @@ dbLoadBalancer = $dbLoadBalancer; } diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index 86a1c4c94b..918c761bca 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -55,7 +55,8 @@ abstract class Skin extends ContextSource { * @return array Associative array of strings */ static function getSkinNames() { - return SkinFactory::getDefaultInstance()->getSkinNames(); + $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); + return $skinFactory->getSkinNames(); } /** @@ -215,6 +216,7 @@ abstract class Skin extends ContextSource { // Preload jquery.tablesorter for mediawiki.page.ready if ( strpos( $out->getHTML(), 'sortable' ) !== false ) { $modules['content'][] = 'jquery.tablesorter'; + $modules['styles']['content'][] = 'jquery.tablesorter.styles'; } // Preload jquery.makeCollapsible for mediawiki.page.ready @@ -387,9 +389,8 @@ abstract class Skin extends ContextSource { /** * Outputs the HTML generated by other functions. - * @param OutputPage|null $out */ - abstract function outputPage( OutputPage $out = null ); + abstract function outputPage(); /** * @param array $data @@ -517,6 +518,7 @@ abstract class Skin extends ContextSource { function getCategoryLinks() { $out = $this->getOutput(); $allCats = $out->getCategoryLinks(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( $allCats === [] ) { return ''; @@ -531,10 +533,10 @@ abstract class Skin extends ContextSource { if ( !empty( $allCats['normal'] ) ) { $t = $embed . implode( $pop . $embed, $allCats['normal'] ) . $pop; - $msg = $this->msg( 'pagecategories' )->numParams( count( $allCats['normal'] ) )->escaped(); + $msg = $this->msg( 'pagecategories' )->numParams( count( $allCats['normal'] ) ); $linkPage = $this->msg( 'pagecategorieslink' )->inContentLanguage()->text(); $title = Title::newFromText( $linkPage ); - $link = $title ? Linker::link( $title, $msg ) : $msg; + $link = $title ? $linkRenderer->makeLink( $title, $msg->text() ) : $msg->escaped(); $s .= ''; } @@ -582,6 +584,7 @@ abstract class Skin extends ContextSource { */ function drawCategoryBrowser( $tree ) { $return = ''; + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); foreach ( $tree as $element => $parent ) { if ( empty( $parent ) ) { @@ -594,7 +597,7 @@ abstract class Skin extends ContextSource { # add our current element to the list $eltitle = Title::newFromText( $element ); - $return .= Linker::link( $eltitle, htmlspecialchars( $eltitle->getText() ) ); + $return .= $linkRenderer->makeLink( $eltitle, $eltitle->getText() ); } return $return; @@ -716,6 +719,7 @@ abstract class Skin extends ContextSource { function getUndeleteLink() { $action = $this->getRequest()->getVal( 'action', 'view' ); $title = $this->getTitle(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( ( !$title->exists() || $action == 'history' ) && $title->quickUserCan( 'deletedhistory', $this->getUser() ) @@ -730,9 +734,9 @@ abstract class Skin extends ContextSource { } return $this->msg( $msg )->rawParams( - Linker::linkKnown( + $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'Undelete', $this->getTitle()->getPrefixedDBkey() ), - $this->msg( 'restorelink' )->numParams( $n )->escaped() ) + $this->msg( 'restorelink' )->numParams( $n )->text() ) )->escaped(); } } @@ -745,6 +749,7 @@ abstract class Skin extends ContextSource { * @return string */ function subPageSubtitle( $out = null ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( $out === null ) { $out = $this->getOutput(); } @@ -774,9 +779,8 @@ abstract class Skin extends ContextSource { $linkObj = Title::newFromText( $growinglink ); if ( is_object( $linkObj ) && $linkObj->isKnown() ) { - $getlink = Linker::linkKnown( - $linkObj, - htmlspecialchars( $display ) + $getlink = $linkRenderer->makeKnownLink( + $linkObj, $display ); $c++; @@ -809,9 +813,11 @@ abstract class Skin extends ContextSource { } /** + * @deprecated since 1.34, use getSearchLink() instead. * @return string */ function escapeSearchLink() { + wfDeprecated( __METHOD__, '1.34' ); return htmlspecialchars( $this->getSearchLink() ); } @@ -820,6 +826,7 @@ abstract class Skin extends ContextSource { * @return string */ function getCopyright( $type = 'detect' ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( $type == 'detect' ) { if ( !$this->isRevisionCurrent() && !$this->msg( 'history_copyright' )->inContentLanguage()->isDisabled() @@ -840,7 +847,9 @@ abstract class Skin extends ContextSource { if ( $config->get( 'RightsPage' ) ) { $title = Title::newFromText( $config->get( 'RightsPage' ) ); - $link = Linker::linkKnown( $title, $config->get( 'RightsText' ) ); + $link = $linkRenderer->makeKnownLink( + $title, new HtmlArmor( $config->get( 'RightsText' ) ) + ); } elseif ( $config->get( 'RightsUrl' ) ) { $link = Linker::makeExternalLink( $config->get( 'RightsUrl' ), $config->get( 'RightsText' ) ); } elseif ( $config->get( 'RightsText' ) ) { @@ -906,7 +915,7 @@ abstract class Skin extends ContextSource { $url2 = htmlspecialchars( "$resourceBasePath/resources/assets/poweredby_mediawiki_176x62.png" ); - $text = 'Powered by MediaWiki'; Hooks::run( 'SkinGetPoweredBy', [ &$text, $this ] ); @@ -996,9 +1005,10 @@ abstract class Skin extends ContextSource { * @return string */ function mainPageLink() { - $s = Linker::linkKnown( + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $s = $linkRenderer->makeKnownLink( Title::newMainPage(), - $this->msg( 'mainpage' )->escaped() + $this->msg( 'mainpage' )->text() ); return $s; @@ -1012,13 +1022,14 @@ abstract class Skin extends ContextSource { */ public function footerLink( $desc, $page ) { $title = $this->footerLinkTitle( $desc, $page ); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( !$title ) { return ''; } - return Linker::linkKnown( + return $linkRenderer->makeKnownLink( $title, - $this->msg( $desc )->escaped() + $this->msg( $desc )->text() ); } @@ -1438,6 +1449,7 @@ abstract class Skin extends ContextSource { $user = $this->getUser(); $newtalks = $user->getNewMessageLinks(); $out = $this->getOutput(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); // Allow extensions to disable or modify the new messages alert if ( !Hooks::run( 'GetNewMessagesAlert', [ &$newMessagesAlert, $newtalks, $user, $out ] ) ) { @@ -1468,16 +1480,16 @@ abstract class Skin extends ContextSource { // 999 signifies "more than one revision". We don't know how many, and even if we did, // the number of revisions or authors is not necessarily the same as the number of // "messages". - $newMessagesLink = Linker::linkKnown( + $newMessagesLink = $linkRenderer->makeKnownLink( $uTalkTitle, - $this->msg( 'newmessageslinkplural' )->params( $plural )->escaped(), + $this->msg( 'newmessageslinkplural' )->params( $plural )->text(), [], $uTalkTitle->isRedirect() ? [ 'redirect' => 'no' ] : [] ); - $newMessagesDiffLink = Linker::linkKnown( + $newMessagesDiffLink = $linkRenderer->makeKnownLink( $uTalkTitle, - $this->msg( 'newmessagesdifflinkplural' )->params( $plural )->escaped(), + $this->msg( 'newmessagesdifflinkplural' )->params( $plural )->text(), [], $lastSeenRev !== null ? [ 'oldid' => $lastSeenRev->getId(), 'diff' => 'cur' ] @@ -1634,11 +1646,12 @@ abstract class Skin extends ContextSource { $result = '['; + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); $linksHtml = []; foreach ( $links as $k => $linkDetails ) { - $linksHtml[] = Linker::linkKnown( + $linksHtml[] = $linkRenderer->makeKnownLink( $linkDetails['targetTitle'], - htmlspecialchars( $linkDetails['text'] ), + $linkDetails['text'], $linkDetails['attribs'], $linkDetails['query'] ); diff --git a/includes/skins/SkinFactory.php b/includes/skins/SkinFactory.php index cc993aaf9b..98d3456adc 100644 --- a/includes/skins/SkinFactory.php +++ b/includes/skins/SkinFactory.php @@ -21,8 +21,6 @@ * @file */ -use MediaWiki\MediaWikiServices; - /** * Factory class to create Skin objects * @@ -43,14 +41,6 @@ class SkinFactory { */ private $displayNames = []; - /** - * @deprecated in 1.27 - * @return SkinFactory - */ - public static function getDefaultInstance() { - return MediaWikiServices::getInstance()->getSkinFactory(); - } - /** * Register a new Skin factory function. * diff --git a/includes/skins/SkinFallbackTemplate.php b/includes/skins/SkinFallbackTemplate.php index bd02fa325d..2f70c8d123 100644 --- a/includes/skins/SkinFallbackTemplate.php +++ b/includes/skins/SkinFallbackTemplate.php @@ -9,6 +9,8 @@ * @file */ +use MediaWiki\MediaWikiServices; + /** * BaseTemplate class for the fallback skin */ @@ -41,7 +43,8 @@ class SkinFallbackTemplate extends BaseTemplate { private function buildHelpfulInformationMessage() { $defaultSkin = $this->config->get( 'DefaultSkin' ); $installedSkins = $this->findInstalledSkins(); - $enabledSkins = SkinFactory::getDefaultInstance()->getSkinNames(); + $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); + $enabledSkins = $skinFactory->getSkinNames(); $enabledSkins = array_change_key_case( $enabledSkins, CASE_LOWER ); if ( $installedSkins ) { diff --git a/includes/skins/SkinTemplate.php b/includes/skins/SkinTemplate.php index ef45d15e2b..5d6197e7ac 100644 --- a/includes/skins/SkinTemplate.php +++ b/includes/skins/SkinTemplate.php @@ -207,21 +207,10 @@ class SkinTemplate extends Skin { } /** - * initialize various variables and generate the template - * - * @param OutputPage|null $out + * Initialize various variables and generate the template */ - function outputPage( OutputPage $out = null ) { + function outputPage() { Profiler::instance()->setTemplated( true ); - - $oldContext = null; - if ( $out !== null ) { - // Deprecated since 1.20, note added in 1.25 - wfDeprecated( __METHOD__, '1.25' ); - $oldContext = $this->getContext(); - $this->setContext( $out->getContext() ); - } - $out = $this->getOutput(); $this->initPage( $out ); @@ -231,10 +220,6 @@ class SkinTemplate extends Skin { // result may be an error $this->printOrError( $res ); - - if ( $oldContext ) { - $this->setContext( $oldContext ); - } } /** @@ -332,7 +317,7 @@ class SkinTemplate extends Skin { $tpl->set( 'handheld', $request->getBool( 'handheld' ) ); $tpl->set( 'loggedin', $this->loggedin ); $tpl->set( 'notspecialpage', !$title->isSpecialPage() ); - $tpl->set( 'searchaction', $this->escapeSearchLink() ); + $tpl->set( 'searchaction', $this->getSearchLink() ); $tpl->set( 'searchtitle', SpecialPage::getTitleFor( 'Search' )->getPrefixedDBkey() ); $tpl->set( 'search', trim( $request->getVal( 'search' ) ) ); $tpl->set( 'stylepath', $wgStylePath ); @@ -622,7 +607,6 @@ class SkinTemplate extends Skin { $returnto['returnto'] = $page; $query = $request->getVal( 'returntoquery', $this->thisquery ); $paramsArray = wfCgiToArray( $query ); - unset( $paramsArray['logoutToken'] ); $query = wfArrayToCgi( $paramsArray ); if ( $query != '' ) { $returnto['returntoquery'] = $query; @@ -695,8 +679,7 @@ class SkinTemplate extends Skin { 'href' => self::makeSpecialUrl( 'Userlogout', // Note: userlogout link must always contain an & character, otherwise we might not be able // to detect a buggy precaching proxy (T19790) - ( $title->isSpecial( 'Preferences' ) ? [] : $returnto ) - + [ 'logoutToken' => $this->getUser()->getEditToken( 'logoutToken', $this->getRequest() ) ] ), + ( $title->isSpecial( 'Preferences' ) ? [] : $returnto ) ), 'active' => false ]; } diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index f4b574be46..f9b4542856 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -159,7 +159,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { [ 'name' => 'userExpLevel', - 'title' => 'rcfilters-filtergroup-userExpLevel', + 'title' => 'rcfilters-filtergroup-user-experience-level', 'class' => ChangesListStringOptionsFilterGroup::class, 'isFullCoverage' => true, 'filters' => [ @@ -354,7 +354,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { [ 'name' => 'lastRevision', - 'title' => 'rcfilters-filtergroup-lastRevision', + 'title' => 'rcfilters-filtergroup-lastrevision', 'class' => ChangesListBooleanFilterGroup::class, 'priority' => -7, 'filters' => [ @@ -824,9 +824,27 @@ abstract class ChangesListSpecialPage extends SpecialPage { } } + /** + * Get essential data about getRcFiltersConfigVars() for change detection. + * + * @internal For use by Resources.php only. + * @see ResourceLoaderModule::getDefinitionSummary() and ResourceLoaderModule::getVersionHash() + * @param ResourceLoaderContext $context + * @return array + */ + public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) { + return [ + // Reduce version computation by avoiding Message parsing + 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ), + 'StructuredChangeFiltersEditWatchlistUrl' => + SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() + ]; + } + /** * Get config vars to export with the mediawiki.rcfilters.filters.ui module. * + * @internal For use by Resources.php only. * @param ResourceLoaderContext $context * @return array */ @@ -839,70 +857,105 @@ abstract class ChangesListSpecialPage extends SpecialPage { } /** - * Fetch the change tags list for the front end + * Get (cheap to compute) information about change tags. + * + * Returns an array of associative arrays with information about each tag: + * - name: Tag name (string) + * - labelMsg: Short description message (Message object) + * - descriptionMsg: Long description message (Message object) + * - cssClass: CSS class to use for RC entries with this tag + * - hits: Number of RC entries that have this tag * * @param ResourceLoaderContext $context - * @return array Tag data + * @return array[] Information about each tag */ - protected static function getChangeTagList( ResourceLoaderContext $context ) { - $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - return $cache->getWithSetCallback( - $cache->makeKey( 'changeslistspecialpage-changetags', $context->getLanguage() ), - $cache::TTL_MINUTE * 10, - function () use ( $context ) { - $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); - $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); - - $tagStats = ChangeTags::tagUsageStatistics(); - $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); - - // Sort by hits (disabled for now) - //arsort( $tagHitCounts ); - - // HACK work around ChangeTags::truncateTagDescription() requiring a RequestContext - $fakeContext = RequestContext::newExtraneousContext( Title::newFromText( 'Dwimmerlaik' ) ); - $fakeContext->setLanguage( Language::factory( $context->getLanguage() ) ); - - // Build the list and data - $result = []; - foreach ( $tagHitCounts as $tagName => $hits ) { - if ( - ( - // Only get active tags - isset( $explicitlyDefinedTags[ $tagName ] ) || - isset( $softwareActivatedTags[ $tagName ] ) - ) && - // Only get tags with more than 0 hits - $hits > 0 - ) { - $result[] = [ - 'name' => $tagName, - 'label' => Sanitizer::stripAllTags( - ChangeTags::tagDescription( $tagName, $context ) - ), - 'description' => - ChangeTags::truncateTagDescription( - $tagName, - self::TAG_DESC_CHARACTER_LIMIT, - $fakeContext - ), - 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), - 'hits' => $hits, - ]; - } + protected static function getChangeTagInfo( ResourceLoaderContext $context ) { + $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); + $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); + + $tagStats = ChangeTags::tagUsageStatistics(); + $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); + + $result = []; + foreach ( $tagHitCounts as $tagName => $hits ) { + if ( + ( + // Only get active tags + isset( $explicitlyDefinedTags[ $tagName ] ) || + isset( $softwareActivatedTags[ $tagName ] ) + ) && + // Only get tags with more than 0 hits + $hits > 0 + ) { + $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context ); + if ( $labelMsg === false ) { + // Tag is hidden, skip it + continue; } + $result[] = [ + 'name' => $tagName, + // 'label' and 'description' filled in by getChangeTagList() + 'labelMsg' => $labelMsg, + 'descriptionMsg' => ChangeTags::tagLongDescriptionMessage( $tagName, $context ), + 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), + 'hits' => $hits, + ]; + } + } + return $result; + } - // Instead of sorting by hit count (disabled, see above), sort by display name - usort( $result, function ( $a, $b ) { - return strcasecmp( $a['label'], $b['label'] ); - } ); + /** + * Get information about change tags for use in getRcFiltersConfigSummary(). + * + * This expands labelMsg and descriptionMsg to the raw values of each message, which captures + * changes in the messages but avoids the expensive step of parsing them. + * + * @param ResourceLoaderContext $context + * @return array[] Result of getChangeTagInfo(), with messages expanded to raw contents + */ + protected static function getChangeTagListSummary( ResourceLoaderContext $context ) { + $tags = self::getChangeTagInfo( $context ); + foreach ( $tags as &$tagInfo ) { + $tagInfo['labelMsg'] = $tagInfo['labelMsg']->plain(); + if ( $tagInfo['descriptionMsg'] ) { + $tagInfo['descriptionMsg'] = $tagInfo['descriptionMsg']->plain(); + } + } + return $tags; + } - return $result; - }, - [ - 'lockTSE' => 30 - ] - ); + /** + * Get information about change tags to export to JS via getRcFiltersConfigVars(). + * + * This removes labelMsg and descriptionMsg, and adds label and description, which are parsed, + * stripped and (in the case of description) truncated versions of these messages. Message + * parsing is expensive, so to detect whether the tag list has changed, use + * getChangeTagListSummary() instead. + * + * @param ResourceLoaderContext $context + * @return array[] Result of getChangeTagInfo(), with messages parsed, stripped and truncated + */ + protected static function getChangeTagList( ResourceLoaderContext $context ) { + $tags = self::getChangeTagInfo( $context ); + $language = Language::factory( $context->getLanguage() ); + foreach ( $tags as &$tagInfo ) { + $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() ); + $tagInfo['description'] = $tagInfo['descriptionMsg'] ? + $language->truncateForVisual( + Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ), + self::TAG_DESC_CHARACTER_LIMIT + ) : + ''; + unset( $tagInfo['labelMsg'] ); + unset( $tagInfo['descriptionMsg'] ); + } + + // Instead of sorting by hit count (disabled for now), sort by display name + usort( $tags, function ( $a, $b ) { + return strcasecmp( $a['label'], $b['label'] ); + } ); + return $tags; } /** @@ -1099,7 +1152,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { * Register all the filters, including legacy hook-driven ones. * Then create a FormOptions object with options as specified by the user * - * @param array $parameters + * @param string $parameters * * @return FormOptions */ @@ -1190,6 +1243,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { // to include data on filters that use the unstructured UI. messageKeys is a // special top-level value, with the value being an array of the message keys to // send to the client. + /** * Gets structured filter information needed by JS * diff --git a/includes/specialpage/QueryPage.php b/includes/specialpage/QueryPage.php index 46873b169a..eb179bf310 100644 --- a/includes/specialpage/QueryPage.php +++ b/includes/specialpage/QueryPage.php @@ -285,21 +285,6 @@ abstract class QueryPage extends SpecialPage { return []; } - /** - * Some special pages (for example SpecialListusers used to) might not return the - * current object formatted, but return the previous one instead. - * Setting this to return true will ensure formatResult() is called - * one more time to make sure that the very last result is formatted - * as well. - * - * @deprecated since 1.27 - * - * @return bool - */ - function tryLastResult() { - return false; - } - /** * Clear the cache and save new results * @@ -384,7 +369,7 @@ abstract class QueryPage extends SpecialPage { /** * Get a DB connection to be used for slow recache queries - * @return \Wikimedia\Rdbms\Database + * @return IDatabase */ function getRecacheDB() { return wfGetDB( DB_REPLICA, [ $this->getName(), 'QueryPage::recache', 'vslow' ] ); @@ -675,7 +660,7 @@ abstract class QueryPage extends SpecialPage { # an OutputPage, and let them get on with it $this->outputResults( $out, $this->getSkin(), - $dbr, # Should use a ResultWrapper for this + $dbr, # Should use IResultWrapper for this $res, min( $this->numRows, $this->limit ), # do not format the one extra row, if exist $this->offset ); @@ -717,17 +702,6 @@ abstract class QueryPage extends SpecialPage { } } - # Flush the final result - if ( $this->tryLastResult() ) { - $row = null; - $line = $this->formatResult( $skin, $row ); - if ( $line ) { - $html[] = $this->listoutput - ? $line - : "
  • {$line}
  • \n"; - } - } - if ( !$this->listoutput ) { $html[] = $this->closeList(); } @@ -764,13 +738,13 @@ abstract class QueryPage extends SpecialPage { } /** - * Creates a new LinkBatch object, adds all pages from the passed ResultWrapper (MUST include + * Creates a new LinkBatch object, adds all pages from the passed result wrapper (MUST include * title and optional the namespace field) and executes the batch. This operation will pre-cache * LinkCache information like page existence and information for stub color and redirect hints. * - * @param IResultWrapper $res The ResultWrapper object to process. Needs to include the title + * @param IResultWrapper $res The result wrapper to process. Needs to include the title * field and namespace field, if the $ns parameter isn't set. - * @param null $ns Use this namespace for the given titles in the ResultWrapper object, + * @param null $ns Use this namespace for the given titles in the result wrapper, * instead of the namespace value of $res. */ protected function executeLBFromResultWrapper( IResultWrapper $res, $ns = null ) { diff --git a/includes/specialpage/SpecialPage.php b/includes/specialpage/SpecialPage.php index bd0e24f2e1..d7e39d5129 100644 --- a/includes/specialpage/SpecialPage.php +++ b/includes/specialpage/SpecialPage.php @@ -24,6 +24,7 @@ use MediaWiki\Auth\AuthManager; use MediaWiki\Linker\LinkRenderer; use MediaWiki\MediaWikiServices; +use MediaWiki\Navigation\PrevNextNavigationRenderer; /** * Parent class for all special pages. @@ -162,6 +163,7 @@ class SpecialPage implements MessageLocalizer { } // @todo FIXME: Decide which syntax to use for this, and stick to it + /** * Whether this special page is listed in Special:SpecialPages * @since 1.3 (r3583) @@ -454,10 +456,10 @@ class SpecialPage implements MessageLocalizer { * For example, if a page supports subpages "foo", "bar" and "baz" (as in Special:PageName/foo, * etc.): * - * - `prefixSearchSubpages( "ba" )` should return `array( "bar", "baz" )` - * - `prefixSearchSubpages( "f" )` should return `array( "foo" )` - * - `prefixSearchSubpages( "z" )` should return `array()` - * - `prefixSearchSubpages( "" )` should return `array( foo", "bar", "baz" )` + * - `prefixSearchSubpages( "ba" )` should return `[ "bar", "baz" ]` + * - `prefixSearchSubpages( "f" )` should return `[ "foo" ]` + * - `prefixSearchSubpages( "z" )` should return `[]` + * - `prefixSearchSubpages( "" )` should return `[ foo", "bar", "baz" ]` * * @param string $search Prefix to search for * @param int $limit Maximum number of results to return (usually 10) @@ -656,18 +658,6 @@ class SpecialPage implements MessageLocalizer { return $this->msg( strtolower( $this->mName ) )->text(); } - /** - * Get a self-referential title object - * - * @param string|bool $subpage - * @return Title - * @deprecated since 1.23, use SpecialPage::getPageTitle - */ - function getTitle( $subpage = false ) { - wfDeprecated( __METHOD__, '1.23' ); - return $this->getPageTitle( $subpage ); - } - /** * Get a self-referential title object * @@ -932,58 +922,11 @@ class SpecialPage implements MessageLocalizer { * @return string */ protected function buildPrevNextNavigation( $offset, $limit, - array $query = [], $atend = false, $subpage = false + array $query = [], $atend = false, $subpage = false ) { - $lang = $this->getLanguage(); - - # Make 'previous' link - $prev = $this->msg( 'prevn' )->numParams( $limit )->text(); - if ( $offset > 0 ) { - $plink = $this->numLink( max( $offset - $limit, 0 ), $limit, $query, - $prev, 'prevn-title', 'mw-prevlink', $subpage ); - } else { - $plink = htmlspecialchars( $prev ); - } - - # Make 'next' link - $next = $this->msg( 'nextn' )->numParams( $limit )->text(); - if ( $atend ) { - $nlink = htmlspecialchars( $next ); - } else { - $nlink = $this->numLink( $offset + $limit, $limit, - $query, $next, 'nextn-title', 'mw-nextlink', $subpage ); - } - - # Make links to set number of items per page - $numLinks = []; - foreach ( [ 20, 50, 100, 250, 500 ] as $num ) { - $numLinks[] = $this->numLink( $offset, $num, $query, - $lang->formatNum( $num ), 'shown-title', 'mw-numlink', $subpage ); - } + $title = $this->getPageTitle( $subpage ); + $prevNext = new PrevNextNavigationRenderer( $this ); - return $this->msg( 'viewprevnext' )->rawParams( $plink, $nlink, $lang->pipeList( $numLinks ) )-> - escaped(); - } - - /** - * Helper function for buildPrevNextNavigation() that generates links - * - * @param int $offset - * @param int $limit - * @param array $query Extra query parameters - * @param string $link Text to use for the link; will be escaped - * @param string $tooltipMsg Name of the message to use as tooltip - * @param string $class Value of the "class" attribute of the link - * @param string|bool $subpage Optional param for specifying subpage - * @return string HTML fragment - */ - private function numLink( $offset, $limit, array $query, $link, - $tooltipMsg, $class, $subpage = false - ) { - $query = [ 'limit' => $limit, 'offset' => $offset ] + $query; - $tooltip = $this->msg( $tooltipMsg )->numParams( $limit )->text(); - $href = $this->getPageTitle( $subpage )->getLocalURL( $query ); - return Html::element( 'a', [ 'href' => $href, - 'title' => $tooltip, 'class' => $class ], $link ); + return $prevNext->buildPrevNextNavigation( $title, $offset, $limit, $query, $atend ); } } diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 1053bda5c4..40172ab693 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -232,6 +232,7 @@ class SpecialPageFactory { 'EmailAuthentication', 'EnableEmail', 'EnableJavaScriptTest', + 'EnableSpecialMute', 'PageLanguageUseDB', 'SpecialPages', ]; @@ -282,9 +283,14 @@ class SpecialPageFactory { $this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class; } + if ( $this->options->get( 'EnableSpecialMute' ) ) { + $this->list['Mute'] = \SpecialMute::class; + } + if ( $this->options->get( 'PageLanguageUseDB' ) ) { $this->list['PageLanguage'] = \SpecialPageLanguage::class; } + if ( $this->options->get( 'ContentHandlerUseDB' ) ) { $this->list['ChangeContentModel'] = \SpecialChangeContentModel::class; } @@ -361,7 +367,7 @@ class SpecialPageFactory { * subpage. * * @param string $alias - * @return array Array( String, String|null ), or array( null, null ) if the page is invalid + * @return array [ String, String|null ], or [ null, null ] if the page is invalid */ public function resolveAlias( $alias ) { $bits = explode( '/', $alias, 2 ); diff --git a/includes/specials/SpecialAllMessages.php b/includes/specials/SpecialAllMessages.php index 878440db39..f6b8b90522 100644 --- a/includes/specials/SpecialAllMessages.php +++ b/includes/specials/SpecialAllMessages.php @@ -43,7 +43,7 @@ class SpecialAllMessages extends SpecialPage { $this->setHeaders(); if ( !$this->getConfig()->get( 'UseDatabaseMessages' ) ) { - $out->addWikiMsg( 'allmessagesnotsupportedDB' ); + $out->addWikiMsg( 'allmessages-not-supported-database' ); return; } @@ -77,10 +77,10 @@ class SpecialAllMessages extends SpecialPage { 'type' => 'radio', 'name' => 'filter', 'label-message' => 'allmessages-filter', - 'options' => [ - $this->msg( 'allmessages-filter-unmodified' )->text() => 'unmodified', - $this->msg( 'allmessages-filter-all' )->text() => 'all', - $this->msg( 'allmessages-filter-modified' )->text() => 'modified', + 'options-messages' => [ + 'allmessages-filter-unmodified' => 'unmodified', + 'allmessages-filter-all' => 'all', + 'allmessages-filter-modified' => 'modified', ], 'default' => 'all', 'flatlist' => true, diff --git a/includes/specials/SpecialBlock.php b/includes/specials/SpecialBlock.php index b79a4827f7..ea4f18d287 100644 --- a/includes/specials/SpecialBlock.php +++ b/includes/specials/SpecialBlock.php @@ -21,6 +21,7 @@ * @ingroup SpecialPage */ +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\MediaWikiServices; @@ -36,7 +37,7 @@ class SpecialBlock extends FormSpecialPage { * or as subpage (Special:Block/Foo) */ protected $target; - /** @var int Block::TYPE_ constant */ + /** @var int DatabaseBlock::TYPE_ constant */ protected $type; /** @var User|string The previous block target */ @@ -101,7 +102,7 @@ class SpecialBlock extends FormSpecialPage { } list( $this->previousTarget, /*...*/ ) = - Block::parseTarget( $request->getVal( 'wpPreviousTarget' ) ); + DatabaseBlock::parseTarget( $request->getVal( 'wpPreviousTarget' ) ); $this->requestedHideUser = $request->getBool( 'wpHideUser' ); } @@ -335,12 +336,12 @@ class SpecialBlock extends FormSpecialPage { # This won't be $fields['PreviousTarget']['default'] = (string)$this->target; - $block = Block::newFromTarget( $this->target ); + $block = DatabaseBlock::newFromTarget( $this->target ); // Populate fields if there is a block that is not an autoblock; if it is a range // block, only populate the fields if the range is the same as $this->target - if ( $block instanceof Block && $block->getType() !== Block::TYPE_AUTO - && ( $this->type != Block::TYPE_RANGE + if ( $block instanceof DatabaseBlock && $block->getType() !== DatabaseBlock::TYPE_AUTO + && ( $this->type != DatabaseBlock::TYPE_RANGE || $block->getTarget() == $this->target ) ) { $fields['HardBlock']['default'] = $block->isHardblock(); @@ -409,13 +410,13 @@ class SpecialBlock extends FormSpecialPage { } if ( $this->getConfig()->get( 'EnablePartialBlocks' ) ) { - if ( $block instanceof Block && !$block->isSitewide() ) { + if ( $block instanceof DatabaseBlock && !$block->isSitewide() ) { $fields['EditingRestriction']['default'] = 'partial'; } else { $fields['EditingRestriction']['default'] = 'sitewide'; } - if ( $block instanceof Block ) { + if ( $block instanceof DatabaseBlock ) { $pageRestrictions = []; $namespaceRestrictions = []; foreach ( $block->getRestrictions() as $restriction ) { @@ -614,11 +615,11 @@ class SpecialBlock extends FormSpecialPage { /** * Determine the target of the block, and the type of target - * @todo Should be in Block.php? + * @todo Should be in DatabaseBlock.php? * @param string $par Subpage parameter passed to setup, or data value from * the HTMLForm * @param WebRequest|null $request Optionally try and get data from a request too - * @return array [ User|string|null, Block::TYPE_ constant|null ] + * @return array [ User|string|null, DatabaseBlock::TYPE_ constant|null ] * @phan-return array{0:User|string|null,1:int|null} */ public static function getTargetAndType( $par, WebRequest $request = null ) { @@ -654,7 +655,7 @@ class SpecialBlock extends FormSpecialPage { break 2; } - list( $target, $type ) = Block::parseTarget( $target ); + list( $target, $type ) = DatabaseBlock::parseTarget( $target ); if ( $type !== null ) { return [ $target, $type ]; @@ -698,7 +699,7 @@ class SpecialBlock extends FormSpecialPage { list( $target, $type ) = self::getTargetAndType( $value ); $status = Status::newGood( $target ); - if ( $type == Block::TYPE_USER ) { + if ( $type == DatabaseBlock::TYPE_USER ) { if ( $target->isAnon() ) { $status->fatal( 'nosuchusershort', @@ -710,7 +711,7 @@ class SpecialBlock extends FormSpecialPage { if ( $unblockStatus !== true ) { $status->fatal( 'badaccess', $unblockStatus ); } - } elseif ( $type == Block::TYPE_RANGE ) { + } elseif ( $type == DatabaseBlock::TYPE_RANGE ) { list( $ip, $range ) = explode( '/', $target, 2 ); if ( @@ -736,7 +737,7 @@ class SpecialBlock extends FormSpecialPage { if ( IP::isIPv6( $ip ) && $range < $wgBlockCIDRLimit['IPv6'] ) { $status->fatal( 'ip_range_toolarge', $wgBlockCIDRLimit['IPv6'] ); } - } elseif ( $type == Block::TYPE_IP ) { + } elseif ( $type == DatabaseBlock::TYPE_IP ) { # All is well } else { $status->fatal( 'badipaddress' ); @@ -750,7 +751,7 @@ class SpecialBlock extends FormSpecialPage { * * @param array $data * @param IContextSource $context - * @return bool|string + * @return bool|array */ public static function processForm( array $data, IContextSource $context ) { global $wgBlockAllowsUTEdit, $wgHideUserContribLimit; @@ -770,7 +771,7 @@ class SpecialBlock extends FormSpecialPage { /** @var User $target */ list( $target, $type ) = self::getTargetAndType( $data['Target'] ); - if ( $type == Block::TYPE_USER ) { + if ( $type == DatabaseBlock::TYPE_USER ) { $user = $target; $target = $user->getName(); $userId = $user->getId(); @@ -787,10 +788,10 @@ class SpecialBlock extends FormSpecialPage { ) { return [ 'ipb-blockingself', 'ipb-confirmaction' ]; } - } elseif ( $type == Block::TYPE_RANGE ) { + } elseif ( $type == DatabaseBlock::TYPE_RANGE ) { $user = null; $userId = 0; - } elseif ( $type == Block::TYPE_IP ) { + } elseif ( $type == DatabaseBlock::TYPE_IP ) { $user = null; $target = $target->getName(); $userId = 0; @@ -842,7 +843,7 @@ class SpecialBlock extends FormSpecialPage { } # Recheck params here... - if ( $type != Block::TYPE_USER ) { + if ( $type != DatabaseBlock::TYPE_USER ) { $data['HideUser'] = false; # IP users should not be hidden } elseif ( !wfIsInfinity( $data['Expiry'] ) ) { # Bad expiry. @@ -860,7 +861,7 @@ class SpecialBlock extends FormSpecialPage { } # Create block object. - $block = new Block(); + $block = new DatabaseBlock(); $block->setTarget( $target ); $block->setBlocker( $performer ); $block->setReason( $data['Reason'][0] ); @@ -923,7 +924,7 @@ class SpecialBlock extends FormSpecialPage { } else { # This returns direct blocks before autoblocks/rangeblocks, since we should # be sure the user is blocked by now it should work for our purposes - $currentBlock = Block::newFromTarget( $target ); + $currentBlock = DatabaseBlock::newFromTarget( $target ); if ( $block->equals( $currentBlock ) ) { return [ [ 'ipb_already_blocked', $block->getTarget() ] ]; } @@ -984,7 +985,7 @@ class SpecialBlock extends FormSpecialPage { } # Can't watch a rangeblock - if ( $type != Block::TYPE_RANGE && $data['Watch'] ) { + if ( $type != DatabaseBlock::TYPE_RANGE && $data['Watch'] ) { WatchAction::doWatch( Title::makeTitle( NS_USER, $target ), $performer, @@ -992,7 +993,7 @@ class SpecialBlock extends FormSpecialPage { ); } - # Block constructor sanitizes certain block options on insert + # DatabaseBlock constructor sanitizes certain block options on insert $data['BlockEmail'] = $block->isEmailBlocked(); $data['AutoBlock'] = $block->isAutoblocking(); @@ -1141,7 +1142,7 @@ class SpecialBlock extends FormSpecialPage { } } elseif ( $target instanceof User && - $performer->getBlock() instanceof Block && + $performer->getBlock() instanceof DatabaseBlock && $performer->getBlock()->getBy() && $performer->getBlock()->getBy() === $target->getId() ) { @@ -1165,7 +1166,7 @@ class SpecialBlock extends FormSpecialPage { * Return a comma-delimited list of "flags" to be passed to the log * reader for this block, to provide more information in the logs * @param array $data From HTMLForm data - * @param int $type Block::TYPE_ constant (USER, RANGE, or IP) + * @param int $type DatabaseBlock::TYPE_ constant (USER, RANGE, or IP) * @return string */ protected static function blockLogFlags( array $data, $type ) { @@ -1177,7 +1178,7 @@ class SpecialBlock extends FormSpecialPage { # when blocking a user the option 'anononly' is not available/has no effect # -> do not write this into log - if ( !$data['HardBlock'] && $type != Block::TYPE_USER ) { + if ( !$data['HardBlock'] && $type != DatabaseBlock::TYPE_USER ) { // For grepping: message block-log-flags-anononly $flags[] = 'anononly'; } @@ -1188,7 +1189,7 @@ class SpecialBlock extends FormSpecialPage { } # Same as anononly, this is not displayed when blocking an IP address - if ( !$data['AutoBlock'] && $type == Block::TYPE_USER ) { + if ( !$data['AutoBlock'] && $type == DatabaseBlock::TYPE_USER ) { // For grepping: message block-log-flags-noautoblock $flags[] = 'noautoblock'; } diff --git a/includes/specials/SpecialBlockList.php b/includes/specials/SpecialBlockList.php index 4e541c9581..9f7381c4cb 100644 --- a/includes/specials/SpecialBlockList.php +++ b/includes/specials/SpecialBlockList.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\Block\DatabaseBlock; + /** * A special page that lists existing blocks * @@ -45,7 +47,7 @@ class SpecialBlockList extends SpecialPage { $this->outputHeader(); $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'ipblocklist' ) ); - $out->addModuleStyles( [ 'mediawiki.special', 'mediawiki.special.blocklist' ] ); + $out->addModuleStyles( [ 'mediawiki.special' ] ); $request = $this->getRequest(); $par = $request->getVal( 'ip', $par ); @@ -139,28 +141,28 @@ class SpecialBlockList extends SpecialPage { } if ( $this->target !== '' ) { - list( $target, $type ) = Block::parseTarget( $this->target ); + list( $target, $type ) = DatabaseBlock::parseTarget( $this->target ); switch ( $type ) { - case Block::TYPE_ID: - case Block::TYPE_AUTO: + case DatabaseBlock::TYPE_ID: + case DatabaseBlock::TYPE_AUTO: $conds['ipb_id'] = $target; break; - case Block::TYPE_IP: - case Block::TYPE_RANGE: + case DatabaseBlock::TYPE_IP: + case DatabaseBlock::TYPE_RANGE: list( $start, $end ) = IP::parseRange( $target ); $conds[] = wfGetDB( DB_REPLICA )->makeList( [ 'ipb_address' => $target, - Block::getRangeCond( $start, $end ) + DatabaseBlock::getRangeCond( $start, $end ) ], LIST_OR ); $conds['ipb_auto'] = 0; break; - case Block::TYPE_USER: + case DatabaseBlock::TYPE_USER: $conds['ipb_address'] = $target->getName(); $conds['ipb_auto'] = 0; break; diff --git a/includes/specials/SpecialChangeCredentials.php b/includes/specials/SpecialChangeCredentials.php index 1d0ff21cf2..f899d76a35 100644 --- a/includes/specials/SpecialChangeCredentials.php +++ b/includes/specials/SpecialChangeCredentials.php @@ -141,9 +141,7 @@ class SpecialChangeCredentials extends AuthManagerSpecialPage { } if ( $any ) { - $this->getOutput()->addModules( [ - 'mediawiki.special.changecredentials.js' - ] ); + $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' ); } return $descriptor; diff --git a/includes/specials/SpecialChangeEmail.php b/includes/specials/SpecialChangeEmail.php index 8d5cf85dce..956ff77e8c 100644 --- a/includes/specials/SpecialChangeEmail.php +++ b/includes/specials/SpecialChangeEmail.php @@ -55,14 +55,16 @@ class SpecialChangeEmail extends FormSpecialPage { * @param string $par */ function execute( $par ) { - $this->checkLoginSecurityLevel(); - $out = $this->getOutput(); $out->disallowUserJs(); parent::execute( $par ); } + protected function getLoginSecurityLevel() { + return $this->getName(); + } + protected function checkExecutePermissions( User $user ) { if ( !AuthManager::singleton()->allowsPropertyChange( 'emailaddress' ) ) { throw new ErrorPageError( 'changeemail', 'cannotchangeemail' ); @@ -76,6 +78,10 @@ class SpecialChangeEmail extends FormSpecialPage { throw new PermissionsError( 'viewmyprivateinfo' ); } + if ( $user->isBlockedFromEmailuser() ) { + throw new UserBlockedError( $user->getBlock() ); + } + parent::checkExecutePermissions( $user ); } @@ -160,6 +166,12 @@ class SpecialChangeEmail extends FormSpecialPage { return Status::newFatal( 'changeemail-nochange' ); } + // To prevent spam, rate limit adding a new address, but do + // not rate limit removing an address. + if ( $newaddr !== '' && $user->pingLimiter( 'changeemail' ) ) { + return Status::newFatal( 'actionthrottledtext' ); + } + $oldaddr = $user->getEmail(); $status = $user->setEmailWithConfirmation( $newaddr ); if ( !$status->isGood() ) { diff --git a/includes/specials/SpecialContributions.php b/includes/specials/SpecialContributions.php index ce083928a7..4f5c15099c 100644 --- a/includes/specials/SpecialContributions.php +++ b/includes/specials/SpecialContributions.php @@ -21,6 +21,7 @@ * @ingroup SpecialPage */ +use MediaWiki\Block\DatabaseBlock; use MediaWiki\MediaWikiServices; use MediaWiki\Widget\DateInputWidget; @@ -320,15 +321,16 @@ class SpecialContributions extends IncludableSpecialPage { // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs, // and also this will display a totally irrelevant log entry as a current block. if ( !$this->including() ) { - // For IP ranges you must give Block::newFromTarget the CIDR string and not a user object. + // For IP ranges you must give DatabaseBlock::newFromTarget the CIDR string + // and not a user object. if ( $userObj->isIPRange() ) { - $block = Block::newFromTarget( $userObj->getName(), $userObj->getName() ); + $block = DatabaseBlock::newFromTarget( $userObj->getName(), $userObj->getName() ); } else { - $block = Block::newFromTarget( $userObj, $userObj ); + $block = DatabaseBlock::newFromTarget( $userObj, $userObj ); } - if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { - if ( $block->getType() == Block::TYPE_RANGE ) { + if ( !is_null( $block ) && $block->getType() != DatabaseBlock::TYPE_AUTO ) { + if ( $block->getType() == DatabaseBlock::TYPE_RANGE ) { $nt = MediaWikiServices::getInstance()->getNamespaceInfo()-> getCanonicalName( NS_USER ) . ':' . $block->getTarget(); } @@ -388,7 +390,7 @@ class SpecialContributions extends IncludableSpecialPage { } if ( $sp->getUser()->isAllowed( 'block' ) ) { # Block / Change block / Unblock links - if ( $target->getBlock() && $target->getBlock()->getType() != Block::TYPE_AUTO ) { + if ( $target->getBlock() && $target->getBlock()->getType() != DatabaseBlock::TYPE_AUTO ) { $tools['block'] = $linkRenderer->makeKnownLink( # Change block link SpecialPage::getTitleFor( 'Block', $username ), $sp->msg( 'change-blocklink' )->text() @@ -623,8 +625,7 @@ class SpecialContributions extends IncludableSpecialPage { [], Xml::label( $this->msg( 'namespace' )->text(), - 'namespace', - '' + 'namespace' ) . "\u{00A0}" . Html::namespaceSelector( [ 'selected' => $this->opts['namespace'], 'all' => '', 'in-user-lang' => true ], diff --git a/includes/specials/SpecialCreateAccount.php b/includes/specials/SpecialCreateAccount.php index 2f87c478bb..8396b4dec1 100644 --- a/includes/specials/SpecialCreateAccount.php +++ b/includes/specials/SpecialCreateAccount.php @@ -23,6 +23,7 @@ use MediaWiki\Auth\AuthManager; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; /** * Implements Special:CreateAccount @@ -65,7 +66,7 @@ class SpecialCreateAccount extends LoginSignupSpecialPage { if ( !$status->isGood() ) { // track block with a cookie if it doesn't exists already if ( $user->isBlockedFromCreateAccount() ) { - $user->trackBlockWithCookie(); + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $user ); } throw new ErrorPageError( 'createacct-error', $status->getMessage() ); } diff --git a/includes/specials/SpecialDeletedContributions.php b/includes/specials/SpecialDeletedContributions.php index 73b438c28d..8817ba3de4 100644 --- a/includes/specials/SpecialDeletedContributions.php +++ b/includes/specials/SpecialDeletedContributions.php @@ -21,6 +21,7 @@ * @ingroup SpecialPage */ +use MediaWiki\Block\DatabaseBlock; use MediaWiki\MediaWikiServices; /** @@ -159,9 +160,9 @@ class DeletedContributionsPage extends SpecialPage { $links = $this->getLanguage()->pipeList( $tools ); // Show a note if the user is blocked and display the last block log entry. - $block = Block::newFromTarget( $userObj, $userObj ); - if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { - if ( $block->getType() == Block::TYPE_RANGE ) { + $block = DatabaseBlock::newFromTarget( $userObj, $userObj ); + if ( !is_null( $block ) && $block->getType() != DatabaseBlock::TYPE_AUTO ) { + if ( $block->getType() == DatabaseBlock::TYPE_RANGE ) { $nt = MediaWikiServices::getInstance()->getNamespaceInfo()-> getCanonicalName( NS_USER ) . ':' . $block->getTarget(); } diff --git a/includes/specials/SpecialEditTags.php b/includes/specials/SpecialEditTags.php index ed398deae4..6ef6cb3f7d 100644 --- a/includes/specials/SpecialEditTags.php +++ b/includes/specials/SpecialEditTags.php @@ -227,6 +227,9 @@ class SpecialEditTags extends UnlistedSpecialPage { $list = $this->getList(); for ( $list->reset(); $list->current(); $list->next() ) { $item = $list->current(); + if ( !$item->canView() ) { + throw new ErrorPageError( 'permissionserrors', 'tags-update-no-permission' ); + } $numRevisions++; $out->addHTML( $item->getHTML() ); } diff --git a/includes/specials/SpecialEmailUser.php b/includes/specials/SpecialEmailUser.php index 5f80215632..b42cdea08a 100644 --- a/includes/specials/SpecialEmailUser.php +++ b/includes/specials/SpecialEmailUser.php @@ -166,14 +166,10 @@ class SpecialEmailUser extends UnlistedSpecialPage { * Validate target User * * @param string $target Target user name - * @param User|null $sender User sending the email + * @param User $sender User sending the email * @return User|string User object on success or a string on error */ - public static function getTarget( $target, User $sender = null ) { - if ( $sender === null ) { - wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); - } - + public static function getTarget( $target, User $sender ) { if ( $target == '' ) { wfDebug( "Target is empty.\n" ); @@ -190,15 +186,11 @@ class SpecialEmailUser extends UnlistedSpecialPage { * Validate target User * * @param User $target Target user - * @param User|null $sender User sending the email + * @param User $sender User sending the email * @return string Error message or empty string if valid. * @since 1.30 */ - public static function validateTarget( $target, User $sender = null ) { - if ( $sender === null ) { - wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); - } - + public static function validateTarget( $target, User $sender ) { if ( !$target instanceof User || !$target->getId() ) { wfDebug( "Target is invalid user.\n" ); @@ -217,25 +209,21 @@ class SpecialEmailUser extends UnlistedSpecialPage { return 'nowikiemail'; } - if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) && - $sender->isNewbie() - ) { + if ( !$target->getOption( 'email-allow-new-users' ) && $sender->isNewbie() ) { wfDebug( "User does not allow user emails from new users.\n" ); return 'nowikiemail'; } - if ( $sender !== null ) { - $blacklist = $target->getOption( 'email-blacklist', '' ); - if ( $blacklist ) { - $blacklist = MultiUsernameFilter::splitIds( $blacklist ); - $lookup = CentralIdLookup::factory(); - $senderId = $lookup->centralIdFromLocalUser( $sender ); - if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) { - wfDebug( "User does not allow user emails from this user.\n" ); + $blacklist = $target->getOption( 'email-blacklist', '' ); + if ( $blacklist ) { + $blacklist = MultiUsernameFilter::splitIds( $blacklist ); + $lookup = CentralIdLookup::factory(); + $senderId = $lookup->centralIdFromLocalUser( $sender ); + if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) { + wfDebug( "User does not allow user emails from this user.\n" ); - return 'nowikiemail'; - } + return 'nowikiemail'; } } @@ -387,6 +375,15 @@ class SpecialEmailUser extends UnlistedSpecialPage { $text .= $context->msg( 'emailuserfooter', $from->name, $to->name )->inContentLanguage()->text(); + if ( $config->get( 'EnableSpecialMute' ) ) { + $specialMutePage = SpecialPage::getTitleFor( 'Mute', $context->getUser()->getName() ); + $text .= "\n" . $context->msg( + 'specialmute-email-footer', + $specialMutePage->getCanonicalURL(), + $context->getUser()->getName() + )->inContentLanguage()->text(); + } + // Check and increment the rate limits if ( $context->getUser()->pingLimiter( 'emailuser' ) ) { throw new ThrottledError(); diff --git a/includes/specials/SpecialExport.php b/includes/specials/SpecialExport.php index ef61ac5b33..5a6358147f 100644 --- a/includes/specials/SpecialExport.php +++ b/includes/specials/SpecialExport.php @@ -24,6 +24,7 @@ */ use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; /** * A special page that allows users to export pages in a XML file @@ -387,6 +388,8 @@ class SpecialExport extends SpecialPage { if ( $exportall ) { $exporter->allPages(); } else { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + foreach ( $pages as $page ) { # T10824: Only export pages the user can read $title = Title::newFromText( $page ); @@ -395,7 +398,7 @@ class SpecialExport extends SpecialPage { continue; } - if ( !$title->userCan( 'read', $this->getUser() ) ) { + if ( !$permissionManager->userCan( 'read', $this->getUser(), $title ) ) { // @todo Perhaps output an tag or something. continue; } diff --git a/includes/specials/SpecialFileDuplicateSearch.php b/includes/specials/SpecialFileDuplicateSearch.php index 98f60723e3..5d8a415a00 100644 --- a/includes/specials/SpecialFileDuplicateSearch.php +++ b/includes/specials/SpecialFileDuplicateSearch.php @@ -1,4 +1,5 @@ hash = ''; $title = Title::newFromText( $this->filename, NS_FILE ); if ( $title && $title->getText() != '' ) { - $this->file = wfFindFile( $title ); + $this->file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); } $out = $this->getOutput(); diff --git a/includes/specials/SpecialImport.php b/includes/specials/SpecialImport.php index 302a55f62a..c3aec83c18 100644 --- a/includes/specials/SpecialImport.php +++ b/includes/specials/SpecialImport.php @@ -322,7 +322,7 @@ class SpecialImport extends SpecialPage { $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ); $user = $this->getUser(); $out = $this->getOutput(); - $this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Import', true ); + $this->addHelpLink( 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Import', true ); if ( $user->isAllowed( 'importupload' ) ) { $mappingSelection = $this->getMappingFormPart( 'upload' ); diff --git a/includes/specials/SpecialJavaScriptTest.php b/includes/specials/SpecialJavaScriptTest.php index c984af8dec..3e9676c64e 100644 --- a/includes/specials/SpecialJavaScriptTest.php +++ b/includes/specials/SpecialJavaScriptTest.php @@ -174,7 +174,7 @@ JAVASCRIPT // load before qunit/export. $scripts = $out->makeResourceLoaderLink( 'jquery.qunit', ResourceLoaderModule::TYPE_SCRIPTS, - [ 'raw' => true, 'sync' => true ] + [ 'raw' => '1', 'sync' => '1' ] ); $head = implode( "\n", [ $styles, $scripts ] ); diff --git a/includes/specials/SpecialLog.php b/includes/specials/SpecialLog.php index 21c166c65d..2f0c2ced53 100644 --- a/includes/specials/SpecialLog.php +++ b/includes/specials/SpecialLog.php @@ -185,7 +185,7 @@ class SpecialLog extends SpecialPage { */ private function parseParams( FormOptions $opts, $par ) { # Get parameters - $par = $par !== null ? $par : ''; + $par = $par ?? ''; $parms = explode( '/', $par ); $symsForAll = [ '*', 'all' ]; if ( $parms[0] != '' && diff --git a/includes/specials/SpecialMovepage.php b/includes/specials/SpecialMovepage.php index b561e5b61c..ecbbc25bca 100644 --- a/includes/specials/SpecialMovepage.php +++ b/includes/specials/SpecialMovepage.php @@ -147,7 +147,7 @@ class MovePageForm extends UnlistedSpecialPage { $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) ); $out->addModuleStyles( 'mediawiki.special' ); - $out->addModules( 'mediawiki.special.movePage' ); + $out->addModules( 'mediawiki.misc-authed-ooui' ); $this->addHelpLink( 'Help:Moving a page' ); $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ? @@ -170,6 +170,8 @@ class MovePageForm extends UnlistedSpecialPage { $deleteAndMove = false; $moveOverShared = false; + $user = $this->getUser(); + $newTitle = $this->newTitle; if ( !$newTitle ) { @@ -180,14 +182,14 @@ class MovePageForm extends UnlistedSpecialPage { # If a title was supplied, probably from the move log revert # link, check for validity. We can then show some diagnostic # information and save a click. - $newerr = $this->oldTitle->isValidMoveOperation( $newTitle ); - if ( is_array( $newerr ) ) { - $err = $newerr; + $mp = new MovePage( $this->oldTitle, $newTitle ); + $status = $mp->isValidMove(); + $status->merge( $mp->checkPermissions( $user, null ) ); + if ( $status->getErrors() ) { + $err = $status->getErrorsArray(); } } - $user = $this->getUser(); - if ( count( $err ) == 1 && isset( $err[0][0] ) && $err[0][0] == 'articleexists' && $newTitle->quickUserCan( 'delete', $user ) ) { @@ -535,7 +537,7 @@ class MovePageForm extends UnlistedSpecialPage { if ( $nt->getNamespace() == NS_FILE && !( $this->moveOverShared && $user->isAllowed( 'reupload-shared' ) ) && !RepoGroup::singleton()->getLocalRepo()->findFile( $nt ) - && wfFindFile( $nt ) + && MediaWikiServices::getInstance()->getRepoGroup()->findFile( $nt ) ) { $this->showForm( [ [ 'file-exists-sharedrepo' ] ] ); @@ -565,7 +567,8 @@ class MovePageForm extends UnlistedSpecialPage { // Delete an associated image if there is if ( $nt->getNamespace() == NS_FILE ) { - $file = wfLocalFile( $nt ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo() + ->newFile( $nt ); $file->load( File::READ_LATEST ); if ( $file->exists() ) { $file->delete( $reason, false, $user ); @@ -594,7 +597,15 @@ class MovePageForm extends UnlistedSpecialPage { # Do the actual move. $mp = new MovePage( $ot, $nt ); + # check whether the requested actions are permitted / possible $userPermitted = $mp->checkPermissions( $user, $this->reason )->isOK(); + if ( $ot->isTalkPage() || $nt->isTalkPage() ) { + $this->moveTalk = false; + } + if ( $this->moveSubpages ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $this->moveSubpages = $permissionManager->userCan( 'move-subpages', $user, $ot ); + } $status = $mp->moveIfAllowed( $user, $this->reason, $createRedirect ); if ( !$status->isOK() ) { @@ -643,19 +654,11 @@ class MovePageForm extends UnlistedSpecialPage { $movePage = $this; Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] ); - # Now we move extra pages we've been asked to move: subpages and talk - # pages. First, if the old page or the new page is a talk page, we - # can't move any talk pages: cancel that. - if ( $ot->isTalkPage() || $nt->isTalkPage() ) { - $this->moveTalk = false; - } - - if ( count( $ot->getUserPermissionsErrors( 'move-subpages', $user ) ) ) { - $this->moveSubpages = false; - } - - /** - * Next make a list of id's. This might be marginally less efficient + /* + * Now we move extra pages we've been asked to move: subpages and talk + * pages. + * + * First, make a list of id's. This might be marginally less efficient * than a more direct method, but this is not a highly performance-cri- * tical code path and readable code is more important here. * @@ -741,14 +744,15 @@ class MovePageForm extends UnlistedSpecialPage { continue; } + $mp = new MovePage( $oldSubpage, $newSubpage ); # This was copy-pasted from Renameuser, bleh. - if ( $newSubpage->exists() && !$oldSubpage->isValidMoveTarget( $newSubpage ) ) { + if ( $newSubpage->exists() && !$mp->isValidMove()->isOk() ) { $link = $linkRenderer->makeKnownLink( $newSubpage ); $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped(); } else { - $success = $oldSubpage->moveTo( $newSubpage, true, $this->reason, $createRedirect ); + $status = $mp->moveIfAllowed( $user, $this->reason, $createRedirect ); - if ( $success === true ) { + if ( $status->isOK() ) { if ( $this->fixRedirects ) { DoubleRedirectJob::fixRedirects( 'move', $oldSubpage, $newSubpage ); } diff --git a/includes/specials/SpecialMute.php b/includes/specials/SpecialMute.php new file mode 100644 index 0000000000..4f34785115 --- /dev/null +++ b/includes/specials/SpecialMute.php @@ -0,0 +1,213 @@ +getConfig(); + $this->enableUserEmailBlacklist = $config->get( 'EnableUserEmailBlacklist' ); + $this->enableUserEmail = $config->get( 'EnableUserEmail' ); + + $this->centralIdLookup = CentralIdLookup::factory(); + + parent::__construct( 'Mute', '', false ); + } + + /** + * Entry point for special pages + * + * @param string $par + */ + public function execute( $par ) { + $this->requireLogin( 'specialmute-login-required' ); + $this->loadTarget( $par ); + + parent::execute( $par ); + + $out = $this->getOutput(); + $out->addModules( 'mediawiki.special.pageLanguage' ); + } + + /** + * @inheritDoc + */ + public function requiresUnblock() { + return false; + } + + /** + * @inheritDoc + */ + protected function getDisplayFormat() { + return 'ooui'; + } + + /** + * @inheritDoc + */ + public function onSuccess() { + $out = $this->getOutput(); + $out->addWikiMsg( 'specialmute-success' ); + } + + /** + * @param array $data + * @param HTMLForm|null $form + * @return bool + */ + public function onSubmit( array $data, HTMLForm $form = null ) { + if ( !empty( $data['MuteEmail'] ) ) { + $this->muteEmailsFromTarget(); + } else { + $this->unmuteEmailsFromTarget(); + } + + return true; + } + + /** + * @inheritDoc + */ + public function getDescription() { + return $this->msg( 'specialmute' )->text(); + } + + /** + * Un-mute emails from target + */ + private function unmuteEmailsFromTarget() { + $blacklist = $this->getBlacklist(); + + $key = array_search( $this->targetCentralId, $blacklist ); + if ( $key !== false ) { + unset( $blacklist[$key] ); + $blacklist = implode( "\n", $blacklist ); + + $user = $this->getUser(); + $user->setOption( 'email-blacklist', $blacklist ); + $user->saveSettings(); + } + } + + /** + * Mute emails from target + */ + private function muteEmailsFromTarget() { + // avoid duplicates just in case + if ( !$this->isTargetBlacklisted() ) { + $blacklist = $this->getBlacklist(); + + $blacklist[] = $this->targetCentralId; + $blacklist = implode( "\n", $blacklist ); + + $user = $this->getUser(); + $user->setOption( 'email-blacklist', $blacklist ); + $user->saveSettings(); + } + } + + /** + * @inheritDoc + */ + protected function alterForm( HTMLForm $form ) { + $form->setId( 'mw-specialmute-form' ); + $form->setHeaderText( $this->msg( 'specialmute-header', $this->target )->parse() ); + $form->setSubmitTextMsg( 'specialmute-submit' ); + $form->setSubmitID( 'save' ); + } + + /** + * @inheritDoc + */ + protected function getFormFields() { + if ( !$this->enableUserEmailBlacklist || !$this->enableUserEmail ) { + throw new ErrorPageError( 'specialmute', 'specialmute-error-email-blacklist-disabled' ); + } + + if ( !$this->getUser()->getEmailAuthenticationTimestamp() ) { + throw new ErrorPageError( 'specialmute', 'specialmute-error-email-preferences' ); + } + + $fields['MuteEmail'] = [ + 'type' => 'check', + 'label-message' => 'specialmute-label-mute-email', + 'default' => $this->isTargetBlacklisted(), + ]; + + return $fields; + } + + /** + * @param string $username + */ + private function loadTarget( $username ) { + $target = User::newFromName( $username ); + if ( !$target || !$target->getId() ) { + throw new ErrorPageError( 'specialmute', 'specialmute-error-invalid-user' ); + } else { + $this->target = $target; + $this->targetCentralId = $this->centralIdLookup->centralIdFromLocalUser( $target ); + } + } + + /** + * @return bool + */ + private function isTargetBlacklisted() { + $blacklist = $this->getBlacklist(); + return in_array( $this->targetCentralId, $blacklist ); + } + + /** + * @return array + */ + private function getBlacklist() { + $blacklist = $this->getUser()->getOption( 'email-blacklist' ); + if ( !$blacklist ) { + return []; + } + + return MultiUsernameFilter::splitIds( $blacklist ); + } +} diff --git a/includes/specials/SpecialNewpages.php b/includes/specials/SpecialNewpages.php index 1b8ba85c70..04db7040dc 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -160,6 +160,8 @@ class SpecialNewpages extends IncludableSpecialPage { $navigation = $pager->getNavigationBar(); } $out->addHTML( $navigation . $pager->getBody() . $navigation ); + // Add styles for change tags + $out->addModuleStyles( 'mediawiki.interface.helpers.styles' ); } else { $out->addWikiMsg( 'specialpage-empty' ); } diff --git a/includes/specials/SpecialPageLanguage.php b/includes/specials/SpecialPageLanguage.php index 7e41305bb1..c0f004ffa6 100644 --- a/includes/specials/SpecialPageLanguage.php +++ b/includes/specials/SpecialPageLanguage.php @@ -43,7 +43,7 @@ class SpecialPageLanguage extends FormSpecialPage { } protected function preText() { - $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' ); + $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' ); return parent::preText(); } diff --git a/includes/specials/SpecialPasswordPolicies.php b/includes/specials/SpecialPasswordPolicies.php index 9bd855ac20..cc8753ceae 100644 --- a/includes/specials/SpecialPasswordPolicies.php +++ b/includes/specials/SpecialPasswordPolicies.php @@ -127,7 +127,7 @@ class SpecialPasswordPolicies extends SpecialPage { * Create a HTML list of password policies for $group * * @param array $policies Original $wgPasswordPolicy array - * @param array $group Group to format password policies for + * @param string $group Group to format password policies for * * @return string HTML list of all applied password policies */ diff --git a/includes/specials/SpecialPermanentLink.php b/includes/specials/SpecialPermanentLink.php index b1772b78e3..71b505aa79 100644 --- a/includes/specials/SpecialPermanentLink.php +++ b/includes/specials/SpecialPermanentLink.php @@ -47,6 +47,7 @@ class SpecialPermanentLink extends RedirectSpecialPage { } protected function showNoRedirectPage() { + $this->addHelpLink( 'Help:PermanentLink' ); $this->setHeaders(); $this->outputHeader(); $this->showForm(); diff --git a/includes/specials/SpecialProtectedtitles.php b/includes/specials/SpecialProtectedtitles.php index 00bfba946d..5dc49ea8c7 100644 --- a/includes/specials/SpecialProtectedtitles.php +++ b/includes/specials/SpecialProtectedtitles.php @@ -136,7 +136,7 @@ class SpecialProtectedtitles extends SpecialPage { /** * @param string $pr_level Determines which option is selected as default - * @return string Formatted HTML + * @return string|array * @private */ function getLevelMenu( $pr_level ) { diff --git a/includes/specials/SpecialRecentChanges.php b/includes/specials/SpecialRecentChanges.php index 9102f81751..6949c6185c 100644 --- a/includes/specials/SpecialRecentChanges.php +++ b/includes/specials/SpecialRecentChanges.php @@ -159,7 +159,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage { } $this->addHelpLink( - '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes', + 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes', true ); parent::execute( $subpage ); diff --git a/includes/specials/SpecialRedirect.php b/includes/specials/SpecialRedirect.php index c4e4635ab5..c1409ffd6f 100644 --- a/includes/specials/SpecialRedirect.php +++ b/includes/specials/SpecialRedirect.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\MediaWikiServices; + /** * A special page that redirects to: the user for a numeric user id, * the file for a given filename, or the page for a given revision id. @@ -61,8 +63,8 @@ class SpecialRedirect extends FormSpecialPage { function setParameter( $subpage ) { // parse $subpage to pull out the parts $parts = explode( '/', $subpage, 2 ); - $this->mType = count( $parts ) > 0 ? $parts[0] : null; - $this->mValue = count( $parts ) > 1 ? $parts[1] : null; + $this->mType = $parts[0]; + $this->mValue = $parts[1] ?? null; } /** @@ -101,7 +103,7 @@ class SpecialRedirect extends FormSpecialPage { } catch ( MalformedTitleException $e ) { return Status::newFatal( $e->getMessageObject() ); } - $file = wfFindFile( $title ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); if ( !$file || !$file->exists() ) { // Message: redirect-not-exists diff --git a/includes/specials/SpecialSearch.php b/includes/specials/SpecialSearch.php index 4adc2475e1..e1fbe6a488 100644 --- a/includes/specials/SpecialSearch.php +++ b/includes/specials/SpecialSearch.php @@ -290,6 +290,7 @@ class SpecialSearch extends SpecialPage { } $out = $this->getOutput(); + $widgetOptions = $this->getConfig()->get( 'SpecialSearchFormOptions' ); $formWidget = new MediaWiki\Widget\Search\SearchFormWidget( $this, $this->searchConfig, @@ -308,19 +309,19 @@ class SpecialSearch extends SpecialPage { // only do the form render here for the empty $term case. Rendering // the form when a search is provided is repeated below. $out->addHTML( $formWidget->render( - $this->profile, $term, 0, 0, $this->offset, $this->isPowerSearch() + $this->profile, $term, 0, 0, $this->offset, $this->isPowerSearch(), $widgetOptions ) ); return; } - $search = $this->getSearchEngine(); - $search->setFeatureData( 'rewrite', $this->runSuggestion ); - $search->setLimitOffset( $this->limit, $this->offset ); - $search->setNamespaces( $this->namespaces ); - $search->setSort( $this->sort ); - $search->prefix = $this->mPrefix; + $engine = $this->getSearchEngine(); + $engine->setFeatureData( 'rewrite', $this->runSuggestion ); + $engine->setLimitOffset( $this->limit, $this->offset ); + $engine->setNamespaces( $this->namespaces ); + $engine->setSort( $this->sort ); + $engine->prefix = $this->mPrefix; - Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $search ] ); + Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $engine ] ); if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) { # Hook requested termination return; @@ -328,17 +329,17 @@ class SpecialSearch extends SpecialPage { $title = Title::newFromText( $term ); $showSuggestion = $title === null || !$title->isKnown(); - $search->setShowSuggestion( $showSuggestion ); + $engine->setShowSuggestion( $showSuggestion ); - $rewritten = $search->replacePrefixes( $term ); + $rewritten = $engine->replacePrefixes( $term ); if ( $rewritten !== $term ) { wfDeprecated( 'SearchEngine::replacePrefixes() (overridden by ' . - get_class( $search ) . ')', '1.32' ); + get_class( $engine ) . ')', '1.32' ); } // fetch search results - $titleMatches = $search->searchTitle( $rewritten ); - $textMatches = $search->searchText( $rewritten ); + $titleMatches = $engine->searchTitle( $rewritten ); + $textMatches = $engine->searchText( $rewritten ); $textStatus = null; if ( $textMatches instanceof Status ) { @@ -356,7 +357,7 @@ class SpecialSearch extends SpecialPage { $textMatchesNum = $textMatches->numRows(); $numTextMatches = $textMatches->getTotalHits(); if ( $textMatchesNum > 0 ) { - $search->augmentSearchResults( $textMatches ); + $engine->augmentSearchResults( $textMatches ); } } $num = $titleMatchesNum + $textMatchesNum; @@ -365,7 +366,7 @@ class SpecialSearch extends SpecialPage { // start rendering the page $out->enableOOUI(); $out->addHTML( $formWidget->render( - $this->profile, $term, $num, $totalRes, $this->offset, $this->isPowerSearch() + $this->profile, $term, $num, $totalRes, $this->offset, $this->isPowerSearch(), $widgetOptions ) ); // did you mean... suggestions @@ -417,14 +418,14 @@ class SpecialSearch extends SpecialPage { $mainResultWidget = new FullSearchResultWidget( $this, $linkRenderer ); // Default (null) on. Can be explicitly disabled. - if ( $search->getFeatureData( 'enable-new-crossproject-page' ) !== false ) { + if ( $engine->getFeatureData( 'enable-new-crossproject-page' ) !== false ) { $sidebarResultWidget = new InterwikiSearchResultWidget( $this, $linkRenderer ); $sidebarResultsWidget = new InterwikiSearchResultSetWidget( $this, $sidebarResultWidget, $linkRenderer, MediaWikiServices::getInstance()->getInterwikiLookup(), - $search->getFeatureData( 'show-multimedia-search-results' ) + $engine->getFeatureData( 'show-multimedia-search-results' ) ); } else { $sidebarResultWidget = new SimpleSearchResultWidget( $this, $linkRenderer ); @@ -462,13 +463,13 @@ class SpecialSearch extends SpecialPage { $offset = $this->offset; } - $prevnext = $this->buildPrevNextNavigation( + $prevNext = $this->buildPrevNextNavigation( $offset, $this->limit, $this->powerSearchOptions() + [ 'search' => $term ], $this->limit + $this->offset >= $totalRes ); - $out->addHTML( "

    {$prevnext}

    \n" ); + $out->addHTML( "

    {$prevNext}

    \n" ); } // Close
    diff --git a/includes/specials/SpecialUnblock.php b/includes/specials/SpecialUnblock.php index a04fe4e16d..31c277a27d 100644 --- a/includes/specials/SpecialUnblock.php +++ b/includes/specials/SpecialUnblock.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\Block\DatabaseBlock; + /** * A special page for unblocking users * @@ -45,7 +47,7 @@ class SpecialUnblock extends SpecialPage { $this->checkReadOnly(); list( $this->target, $this->type ) = SpecialBlock::getTargetAndType( $par, $this->getRequest() ); - $this->block = Block::newFromTarget( $this->target ); + $this->block = DatabaseBlock::newFromTarget( $this->target ); if ( $this->target instanceof User ) { # Set the 'relevant user' in the skin, so it displays links like Contributions, # User logs, UserRights, etc. @@ -54,6 +56,7 @@ class SpecialUnblock extends SpecialPage { $this->setHeaders(); $this->outputHeader(); + $this->addHelpLink( 'Help:Blocking users' ); $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'unblockip' ) ); @@ -67,17 +70,17 @@ class SpecialUnblock extends SpecialPage { if ( $form->show() ) { switch ( $this->type ) { - case Block::TYPE_IP: + case DatabaseBlock::TYPE_IP: $out->addWikiMsg( 'unblocked-ip', wfEscapeWikiText( $this->target ) ); break; - case Block::TYPE_USER: + case DatabaseBlock::TYPE_USER: $out->addWikiMsg( 'unblocked', wfEscapeWikiText( $this->target ) ); break; - case Block::TYPE_RANGE: + case DatabaseBlock::TYPE_RANGE: $out->addWikiMsg( 'unblocked-range', wfEscapeWikiText( $this->target ) ); break; - case Block::TYPE_ID: - case Block::TYPE_AUTO: + case DatabaseBlock::TYPE_ID: + case DatabaseBlock::TYPE_AUTO: $out->addWikiMsg( 'unblocked-id', wfEscapeWikiText( $this->target ) ); break; } @@ -104,28 +107,28 @@ class SpecialUnblock extends SpecialPage { ] ]; - if ( $this->block instanceof Block ) { + if ( $this->block instanceof DatabaseBlock ) { list( $target, $type ) = $this->block->getTargetAndType(); # Autoblocks are logged as "autoblock #123 because the IP was recently used by # User:Foo, and we've just got any block, auto or not, that applies to a target # the user has specified. Someone could be fishing to connect IPs to autoblocks, # so don't show any distinction between unblocked IPs and autoblocked IPs - if ( $type == Block::TYPE_AUTO && $this->type == Block::TYPE_IP ) { + if ( $type == DatabaseBlock::TYPE_AUTO && $this->type == DatabaseBlock::TYPE_IP ) { $fields['Target']['default'] = $this->target; unset( $fields['Name'] ); } else { $fields['Target']['default'] = $target; $fields['Target']['type'] = 'hidden'; switch ( $type ) { - case Block::TYPE_IP: + case DatabaseBlock::TYPE_IP: $fields['Name']['default'] = $this->getLinkRenderer()->makeKnownLink( SpecialPage::getTitleFor( 'Contributions', $target->getName() ), $target->getName() ); $fields['Name']['raw'] = true; break; - case Block::TYPE_USER: + case DatabaseBlock::TYPE_USER: $fields['Name']['default'] = $this->getLinkRenderer()->makeLink( $target->getUserPage(), $target->getName() @@ -133,11 +136,11 @@ class SpecialUnblock extends SpecialPage { $fields['Name']['raw'] = true; break; - case Block::TYPE_RANGE: + case DatabaseBlock::TYPE_RANGE: $fields['Name']['default'] = $target; break; - case Block::TYPE_AUTO: + case DatabaseBlock::TYPE_AUTO: $fields['Name']['default'] = $this->block->getRedactedName(); $fields['Name']['raw'] = true; # Don't expose the real target of the autoblock @@ -160,7 +163,7 @@ class SpecialUnblock extends SpecialPage { * Submit callback for an HTMLForm object * @param array $data * @param HTMLForm $form - * @return array|bool Array(message key, parameters) + * @return array|bool [ message key, parameters ] */ public static function processUIUnblock( array $data, HTMLForm $form ) { return self::processUnblock( $data, $form->getContext() ); @@ -175,14 +178,14 @@ class SpecialUnblock extends SpecialPage { * @param array $data * @param IContextSource $context * @throws ErrorPageError - * @return array|bool Array( Array( message key, parameters ) ) on failure, True on success + * @return array|bool [ [ message key, parameters ] ] on failure, True on success */ public static function processUnblock( array $data, IContextSource $context ) { $performer = $context->getUser(); $target = $data['Target']; - $block = Block::newFromTarget( $data['Target'] ); + $block = DatabaseBlock::newFromTarget( $data['Target'] ); - if ( !$block instanceof Block ) { + if ( !$block instanceof DatabaseBlock ) { return [ [ 'ipb_cant_unblock', $target ] ]; } @@ -197,7 +200,7 @@ class SpecialUnblock extends SpecialPage { # If the specified IP is a single address, and the block is a range block, don't # unblock the whole range. list( $target, $type ) = SpecialBlock::getTargetAndType( $target ); - if ( $block->getType() == Block::TYPE_RANGE && $type == Block::TYPE_IP ) { + if ( $block->getType() == DatabaseBlock::TYPE_RANGE && $type == DatabaseBlock::TYPE_IP ) { $range = $block->getTarget(); return [ [ 'ipb_blocked_as_range', $target, $range ] ]; @@ -232,7 +235,7 @@ class SpecialUnblock extends SpecialPage { } # Redact the name (IP address) for autoblocks - if ( $block->getType() == Block::TYPE_AUTO ) { + if ( $block->getType() == DatabaseBlock::TYPE_AUTO ) { $page = Title::makeTitle( NS_USER, '#' . $block->getId() ); } else { $page = $block->getTarget() instanceof User diff --git a/includes/specials/SpecialUncategorizedpages.php b/includes/specials/SpecialUncategorizedpages.php index 9efa8032c8..ab83af1c92 100644 --- a/includes/specials/SpecialUncategorizedpages.php +++ b/includes/specials/SpecialUncategorizedpages.php @@ -30,6 +30,7 @@ use MediaWiki\MediaWikiServices; * @todo FIXME: Make $requestedNamespace selectable, unify all subclasses into one */ class UncategorizedPagesPage extends PageQueryPage { + /** @var int|false */ protected $requestedNamespace = false; function __construct( $name = 'Uncategorizedpages' ) { diff --git a/includes/specials/SpecialUndelete.php b/includes/specials/SpecialUndelete.php index 456facef12..95563d282c 100644 --- a/includes/specials/SpecialUndelete.php +++ b/includes/specials/SpecialUndelete.php @@ -138,8 +138,10 @@ class SpecialUndelete extends SpecialPage { */ protected function isAllowed( $permission, User $user = null ) { $user = $user ?: $this->getUser(); + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + if ( $this->mTargetObj !== null ) { - return $this->mTargetObj->userCan( $permission, $user ); + return $permissionManager->userCan( $permission, $user, $this->mTargetObj ); } else { return $user->isAllowed( $permission ); } @@ -185,7 +187,7 @@ class SpecialUndelete extends SpecialPage { if ( $this->mTimestamp !== '' ) { $this->showRevision( $this->mTimestamp ); } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) { - $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); // Check if user is allowed to see this file if ( !$file->exists() ) { $out->addWikiMsg( 'filedelete-nofile', $this->mFilename ); @@ -649,7 +651,7 @@ class SpecialUndelete extends SpecialPage { $out = $this->getOutput(); $lang = $this->getLanguage(); $user = $this->getUser(); - $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); $out->addWikiMsg( 'undelete-show-file-confirm', $this->mTargetObj->getText(), $lang->userDate( $file->getTimestamp(), $user ), diff --git a/includes/specials/SpecialUpload.php b/includes/specials/SpecialUpload.php index dcc35fcef7..68fda497b7 100644 --- a/includes/specials/SpecialUpload.php +++ b/includes/specials/SpecialUpload.php @@ -669,7 +669,8 @@ class SpecialUpload extends SpecialPage { return true; } - $local = wfLocalFile( $this->mDesiredDestName ); + $local = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo() + ->newFile( $this->mDesiredDestName ); if ( $local && $local->exists() ) { // We're uploading a new version of an existing file. // No creation, so don't watch it if we're not already. diff --git a/includes/specials/SpecialUploadStash.php b/includes/specials/SpecialUploadStash.php index c278babd64..dac19a3135 100644 --- a/includes/specials/SpecialUploadStash.php +++ b/includes/specials/SpecialUploadStash.php @@ -62,7 +62,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * * @param string|null $subPage Subpage, e.g. in * https://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part - * @return bool Success */ public function execute( $subPage ) { $this->useTransactionalTimeLimit(); @@ -71,10 +70,10 @@ class SpecialUploadStash extends UnlistedSpecialPage { $this->checkPermissions(); if ( $subPage === null || $subPage === '' ) { - return $this->showUploads(); + $this->showUploads(); + } else { + $this->showUpload( $subPage ); } - - return $this->showUpload( $subPage ); } /** @@ -83,7 +82,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * * @param string $key The key of a particular requested file * @throws HttpError - * @return bool */ public function showUpload( $key ) { // prevent callers from doing standard HTML output -- we'll take it from here @@ -92,10 +90,11 @@ class SpecialUploadStash extends UnlistedSpecialPage { try { $params = $this->parseKey( $key ); if ( $params['type'] === 'thumb' ) { - return $this->outputThumbFromStash( $params['file'], $params['params'] ); + $this->outputThumbFromStash( $params['file'], $params['params'] ); } else { - return $this->outputLocalFile( $params['file'] ); + $this->outputLocalFile( $params['file'] ); } + return; } catch ( UploadStashFileNotFoundException $e ) { $code = 404; $message = $e->getMessage(); @@ -187,7 +186,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); * @param int $flags Scaling flags ( see File:: constants ) * @throws MWException|UploadStashFileNotFoundException - * @return bool Success */ private function outputLocallyScaledThumb( $file, $params, $flags ) { // n.b. this is stupid, we insist on re-transforming the file every time we are invoked. We rely @@ -219,7 +217,7 @@ class SpecialUploadStash extends UnlistedSpecialPage { ); } - return $this->outputLocalFile( $thumbFile ); + $this->outputLocalFile( $thumbFile ); } /** @@ -239,7 +237,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); * @param int $flags Scaling flags ( see File:: constants ) * @throws MWException - * @return bool Success */ private function outputRemoteScaledThumb( $file, $params, $flags ) { // This option probably looks something like @@ -303,7 +300,7 @@ class SpecialUploadStash extends UnlistedSpecialPage { ); } - return $this->outputContents( $req->getContent(), $contentType ); + $this->outputContents( $req->getContent(), $contentType ); } /** @@ -313,7 +310,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * @param File $file File object with a local path (e.g. UnregisteredLocalFile, * LocalFile. Oddly these don't share an ancestor!) * @throws SpecialUploadStashTooLargeException - * @return bool */ private function outputLocalFile( File $file ) { if ( $file->getSize() > self::MAX_SERVE_BYTES ) { @@ -322,10 +318,10 @@ class SpecialUploadStash extends UnlistedSpecialPage { ); } - return $file->getRepo()->streamFileWithStatus( $file->getPath(), + $file->getRepo()->streamFileWithStatus( $file->getPath(), [ 'Content-Transfer-Encoding: binary', 'Expires: Sun, 17-Jan-2038 19:14:07 GMT' ] - )->isOK(); + ); } /** @@ -334,7 +330,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { * @param string $content * @param string $contentType MIME type * @throws SpecialUploadStashTooLargeException - * @return bool */ private function outputContents( $content, $contentType ) { $size = strlen( $content ); @@ -347,8 +342,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { wfResetOutputBuffers(); self::outputFileHeaders( $contentType, $size ); print $content; - - return true; } /** @@ -394,7 +387,6 @@ class SpecialUploadStash extends UnlistedSpecialPage { /** * Default action when we don't have a subpage -- just show links to the uploads we have, * Also show a button to clear stashed files - * @return bool */ private function showUploads() { // sets the title, etc. @@ -459,7 +451,5 @@ class SpecialUploadStash extends UnlistedSpecialPage { . $refreshHtml ) ); } - - return true; } } diff --git a/includes/specials/SpecialUserLogout.php b/includes/specials/SpecialUserLogout.php index 568327d25b..62010d9fd6 100644 --- a/includes/specials/SpecialUserLogout.php +++ b/includes/specials/SpecialUserLogout.php @@ -26,7 +26,7 @@ * * @ingroup SpecialPage */ -class SpecialUserLogout extends UnlistedSpecialPage { +class SpecialUserLogout extends FormSpecialPage { function __construct() { parent::__construct( 'Userlogout' ); } @@ -35,41 +35,49 @@ class SpecialUserLogout extends UnlistedSpecialPage { return true; } - function execute( $par ) { - /** - * Some satellite ISPs use broken precaching schemes that log people out straight after - * they're logged in (T19790). Luckily, there's a way to detect such requests. - */ - if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&' ) !== false ) { - wfDebug( "Special:UserLogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" ); - throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) ); - } + public function isListed() { + return false; + } - $this->setHeaders(); - $this->outputHeader(); + protected function getGroupName() { + return 'login'; + } - $out = $this->getOutput(); - $user = $this->getUser(); - $request = $this->getRequest(); + protected function getFormFields() { + return []; + } - $logoutToken = $request->getVal( 'logoutToken' ); - $urlParams = [ - 'logoutToken' => $user->getEditToken( 'logoutToken', $request ) - ] + $request->getValues(); - unset( $urlParams['title'] ); - $continueLink = $this->getFullTitle()->getFullUrl( $urlParams ); + protected function getDisplayFormat() { + return 'ooui'; + } - if ( $logoutToken === null ) { - $this->getOutput()->addWikiMsg( 'userlogout-continue', $continueLink ); - return; - } - if ( !$this->getUser()->matchEditToken( - $logoutToken, 'logoutToken', $this->getRequest(), 24 * 60 * 60 - ) ) { - $this->getOutput()->addWikiMsg( 'userlogout-sessionerror', $continueLink ); + public function execute( $par ) { + if ( $this->getUser()->isAnon() ) { + $this->setHeaders(); + $this->showSuccess(); return; } + parent::execute( $par ); + } + + public function alterForm( HTMLForm $form ) { + $form->setTokenSalt( 'logoutToken' ); + $form->addHeaderText( $this->msg( 'userlogout-continue' ) ); + + $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + } + + /** + * Process the form. At this point we know that the user passes all the criteria in + * userCanExecute(), and if the data array contains 'Username', etc, then Username + * resets are allowed. + * @param array $data + * @throws MWException + * @throws ThrottledError|PermissionsError + * @return Status + */ + public function onSubmit( array $data ) { // Make sure it's possible to log out $session = MediaWiki\Session\SessionManager::getGlobalSession(); if ( !$session->canSetUser() ) { @@ -83,25 +91,37 @@ class SpecialUserLogout extends UnlistedSpecialPage { } $user = $this->getUser(); - $oldName = $user->getName(); $user->logout(); + return new Status(); + } - $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( - $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + public function onSuccess() { + $this->showSuccess(); + $user = $this->getUser(); + $oldName = $user->getName(); $out = $this->getOutput(); - $out->addWikiMsg( 'logouttext', $loginURL ); - // Hook. $injected_html = ''; Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] ); $out->addHTML( $injected_html ); + } + + private function showSuccess() { + $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( + $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + + $out = $this->getOutput(); + $out->addWikiMsg( 'logouttext', $loginURL ); $out->returnToMain(); } - protected function getGroupName() { - return 'login'; + /** + * Let blocked users to log out and come back with their sockpuppets + */ + public function requiresUnblock() { + return false; } } diff --git a/includes/specials/SpecialUserrights.php b/includes/specials/SpecialUserrights.php index 8655b1c2e8..a8ff32fa90 100644 --- a/includes/specials/SpecialUserrights.php +++ b/includes/specials/SpecialUserrights.php @@ -156,10 +156,10 @@ class UserrightsPage extends SpecialPage { $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget ) ) { /* - * If the user is blocked and they only have "partial" access - * (e.g. they don't have the userrights permission), then don't - * allow them to change any user rights. - */ + * If the user is blocked and they only have "partial" access + * (e.g. they don't have the userrights permission), then don't + * allow them to change any user rights. + */ if ( !$user->isAllowed( 'userrights' ) ) { // @TODO Should the user be blocked from changing user rights if they // are partially blocked? @@ -405,8 +405,6 @@ class UserrightsPage extends SpecialPage { wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" ); wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" ); wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" ); - // Deprecated in favor of UserGroupsChanged hook - Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' ); // Only add a log entry if something actually changed if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) { @@ -509,18 +507,18 @@ class UserrightsPage extends SpecialPage { $parts = explode( $this->getConfig()->get( 'UserrightsInterwikiDelimiter' ), $username ); if ( count( $parts ) < 2 ) { $name = trim( $username ); - $wikiId = ''; + $dbDomain = ''; } else { - list( $name, $wikiId ) = array_map( 'trim', $parts ); + list( $name, $dbDomain ) = array_map( 'trim', $parts ); - if ( WikiMap::isCurrentWikiId( $wikiId ) ) { - $wikiId = ''; + if ( WikiMap::isCurrentWikiId( $dbDomain ) ) { + $dbDomain = ''; } else { if ( $writing && !$this->getUser()->isAllowed( 'userrights-interwiki' ) ) { return Status::newFatal( 'userrights-no-interwiki' ); } - if ( !UserRightsProxy::validDatabase( $wikiId ) ) { - return Status::newFatal( 'userrights-nodatabase', $wikiId ); + if ( !UserRightsProxy::validDatabase( $dbDomain ) ) { + return Status::newFatal( 'userrights-nodatabase', $dbDomain ); } } } @@ -534,10 +532,10 @@ class UserrightsPage extends SpecialPage { // We'll do a lookup for the name internally. $id = intval( substr( $name, 1 ) ); - if ( $wikiId == '' ) { + if ( $dbDomain == '' ) { $name = User::whoIs( $id ); } else { - $name = UserRightsProxy::whoIs( $wikiId, $id ); + $name = UserRightsProxy::whoIs( $dbDomain, $id ); } if ( !$name ) { @@ -551,10 +549,10 @@ class UserrightsPage extends SpecialPage { } } - if ( $wikiId == '' ) { + if ( $dbDomain == '' ) { $user = User::newFromName( $name ); } else { - $user = UserRightsProxy::newFromName( $wikiId, $name ); + $user = UserRightsProxy::newFromName( $dbDomain, $name ); } if ( !$user || $user->isAnon() ) { @@ -1001,12 +999,12 @@ class UserrightsPage extends SpecialPage { /** * Returns $this->getUser()->changeableGroups() * - * @return array Array( - * 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self ), - * 'remove-self' => array( removable groups from self ) - * ) + * @return array [ + * 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] + * ] */ function changeableGroups() { return $this->getUser()->changeableGroups(); diff --git a/includes/specials/SpecialVersion.php b/includes/specials/SpecialVersion.php index 0c4959a48e..ec34db8611 100644 --- a/includes/specials/SpecialVersion.php +++ b/includes/specials/SpecialVersion.php @@ -219,23 +219,26 @@ class SpecialVersion extends SpecialPage { } /** - * Returns wiki text showing the third party software versions (apache, php, mysql). + * @since 1.34 * - * @return string + * @return array */ - public static function softwareInformation() { + public static function getSoftwareInformation() { $dbr = wfGetDB( DB_REPLICA ); // Put the software in an array of form 'name' => 'version'. All messages should // be loaded here, so feel free to use wfMessage in the 'name'. Raw HTML or // wikimarkup can be used. - $software = []; - $software['[https://www.mediawiki.org/ MediaWiki]'] = self::getVersionLinked(); + $software = [ + '[https://www.mediawiki.org/ MediaWiki]' => self::getVersionLinked() + ]; + if ( wfIsHHVM() ) { $software['[https://hhvm.com/ HHVM]'] = HHVM_VERSION . " (" . PHP_SAPI . ")"; } else { $software['[https://php.net/ PHP]'] = PHP_VERSION . " (" . PHP_SAPI . ")"; } + $software[$dbr->getSoftwareLink()] = $dbr->getServerInfo(); if ( defined( 'INTL_ICU_VERSION' ) ) { @@ -245,18 +248,27 @@ class SpecialVersion extends SpecialPage { // Allow a hook to add/remove items. Hooks::run( 'SoftwareInfo', [ &$software ] ); + return $software; + } + + /** + * Returns HTML showing the third party software versions (apache, php, mysql). + * + * @return string HTML table + */ + public static function softwareInformation() { $out = Xml::element( 'h2', [ 'id' => 'mw-version-software' ], wfMessage( 'version-software' )->text() ) . - Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) . - " - " . wfMessage( 'version-software-product' )->text() . " - " . wfMessage( 'version-software-version' )->text() . " - \n"; + Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) . + " + " . wfMessage( 'version-software-product' )->text() . " + " . wfMessage( 'version-software-version' )->text() . " + \n"; - foreach ( $software as $name => $version ) { + foreach ( self::getSoftwareInformation() as $name => $version ) { $out .= " " . $name . " " . $version . " @@ -812,7 +824,7 @@ class SpecialVersion extends SpecialPage { } // ... and generate the description; which can be a parameterized l10n message - // in the form array( , , ... ) or just a straight + // in the form [ , , ... ] or just a straight // up string if ( isset( $extension['descriptionmsg'] ) ) { // Localized description of extension diff --git a/includes/specials/SpecialWantedfiles.php b/includes/specials/SpecialWantedfiles.php index 2ebbc2d86c..aa3a971d96 100644 --- a/includes/specials/SpecialWantedfiles.php +++ b/includes/specials/SpecialWantedfiles.php @@ -24,6 +24,8 @@ * @author Soxred93 */ +use MediaWiki\MediaWikiServices; + /** * Querypage that lists the most wanted files * @@ -97,14 +99,14 @@ class WantedFilesPage extends WantedQueryPage { /** * Does the file exist? * - * Use wfFindFile so we still think file namespace pages without - * files are missing, but valid file redirects and foreign files are ok. + * Use findFile() so we still think file namespace pages without files + * are missing, but valid file redirects and foreign files are ok. * * @param Title $title * @return bool */ protected function existenceCheck( Title $title ) { - return (bool)wfFindFile( $title ); + return (bool)MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); } function getQueryInfo() { diff --git a/includes/specials/SpecialWatchlist.php b/includes/specials/SpecialWatchlist.php index 812f1b00ab..2443470ef5 100644 --- a/includes/specials/SpecialWatchlist.php +++ b/includes/specials/SpecialWatchlist.php @@ -554,6 +554,9 @@ class SpecialWatchlist extends ChangesListSpecialPage { $rc->numberofWatchingusers = 0; } + // XXX: this treats pages with no unseen changes as "not on the watchlist" since + // everything is on the watchlist and it is an easy way to make pages with unseen + // changes appear bold. @TODO: clean this up. $changeLine = $list->recentChangesLine( $rc, $unseen, $counter ); if ( $changeLine !== false ) { $s .= $changeLine; @@ -865,19 +868,19 @@ class SpecialWatchlist extends ChangesListSpecialPage { * @return bool User viewed the revision or a newer one */ protected function isChangeEffectivelySeen( RecentChange $rc ) { - $lastVisitTs = $this->getLatestSeenTimestampIfHasUnseen( $rc ); + $firstUnseen = $this->getLatestNotificationTimestamp( $rc ); - return $lastVisitTs === null || $lastVisitTs > $rc->getAttribute( 'rc_timestamp' ); + return ( $firstUnseen === null || $firstUnseen > $rc->getAttribute( 'rc_timestamp' ) ); } /** * @param RecentChange $rc - * @return string|null TS_MW timestamp or null if all revision were seen + * @return string|null TS_MW timestamp of first unseen revision or null if there isn't one */ - private function getLatestSeenTimestampIfHasUnseen( RecentChange $rc ) { + private function getLatestNotificationTimestamp( RecentChange $rc ) { return $this->watchStore->getLatestNotificationTimestamp( $rc->getAttribute( 'wl_notificationtimestamp' ), - $rc->getPerformer(), + $this->getUser(), $rc->getTitle() ); } diff --git a/includes/specials/forms/PreferencesFormOOUI.php b/includes/specials/forms/PreferencesFormOOUI.php index 8358cd2bf3..5dae156993 100644 --- a/includes/specials/forms/PreferencesFormOOUI.php +++ b/includes/specials/forms/PreferencesFormOOUI.php @@ -146,9 +146,8 @@ class PreferencesFormOOUI extends OOUIHTMLForm { ) . $this->getFooterText( $key ); - $tabPanels[] = new OOUI\TabPanelLayout( [ + $tabPanels[] = new OOUI\TabPanelLayout( 'mw-prefsection-' . $key, [ 'classes' => [ 'mw-htmlform-autoinfuse-lazy' ], - 'name' => 'mw-prefsection-' . $key, 'label' => $label, 'content' => new OOUI\FieldsetLayout( [ 'classes' => [ 'mw-prefs-section-fieldset' ], @@ -195,7 +194,7 @@ class PreferencesFormOOUI extends OOUIHTMLForm { /** * Get the keys of each top level preference section. - * @return array of section keys + * @return string[] List of section keys */ function getPreferenceSections() { return array_keys( array_filter( $this->mFieldTree, 'is_array' ) ); diff --git a/includes/specials/helpers/LoginHelper.php b/includes/specials/helpers/LoginHelper.php index 6c9bea598f..f66eccf7bc 100644 --- a/includes/specials/helpers/LoginHelper.php +++ b/includes/specials/helpers/LoginHelper.php @@ -25,6 +25,7 @@ class LoginHelper extends ContextSource { 'resetpass-no-info', 'confirmemail_needlogin', 'prefsnologintext2', + 'specialmute-login-required', ]; /** diff --git a/includes/specials/pagers/AllMessagesTablePager.php b/includes/specials/pagers/AllMessagesTablePager.php index 8120417b46..76e2ab7754 100644 --- a/includes/specials/pagers/AllMessagesTablePager.php +++ b/includes/specials/pagers/AllMessagesTablePager.php @@ -176,11 +176,11 @@ class AllMessagesTablePager extends TablePager { */ function reallyDoQuery( $offset, $limit, $order ) { $asc = ( $order === self::QUERY_ASCENDING ); - $result = new FakeResultWrapper( [] ); $messageNames = $this->getAllMessages( $order ); $statuses = self::getCustomisedStatuses( $messageNames, $this->langcode, $this->foreign ); + $rows = []; $count = 0; foreach ( $messageNames as $key ) { $customised = isset( $statuses['pages'][$key] ); @@ -188,9 +188,9 @@ class AllMessagesTablePager extends TablePager { ( $asc && ( $key < $offset || !$offset ) || !$asc && $key > $offset ) && ( ( $this->prefix && preg_match( $this->prefix, $key ) ) || $this->prefix === false ) ) { - $actual = wfMessage( $key )->inLanguage( $this->lang )->plain(); - $default = wfMessage( $key )->inLanguage( $this->lang )->useDatabase( false )->plain(); - $result->result[] = [ + $actual = $this->msg( $key )->inLanguage( $this->lang )->plain(); + $default = $this->msg( $key )->inLanguage( $this->lang )->useDatabase( false )->plain(); + $rows[] = [ 'am_title' => $key, 'am_actual' => $actual, 'am_default' => $default, @@ -205,7 +205,7 @@ class AllMessagesTablePager extends TablePager { } } - return $result; + return new FakeResultWrapper( $rows ); } protected function getStartBody() { diff --git a/includes/specials/pagers/BlockListPager.php b/includes/specials/pagers/BlockListPager.php index d09b3451b9..01aed22726 100644 --- a/includes/specials/pagers/BlockListPager.php +++ b/includes/specials/pagers/BlockListPager.php @@ -22,6 +22,7 @@ /** * @ingroup Pager */ +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\Restriction\Restriction; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\Restriction\NamespaceRestriction; @@ -107,10 +108,10 @@ class BlockListPager extends TablePager { if ( $row->ipb_auto ) { $formatted = $this->msg( 'autoblockid', $row->ipb_id )->parse(); } else { - list( $target, $type ) = Block::parseTarget( $row->ipb_address ); + list( $target, $type ) = DatabaseBlock::parseTarget( $row->ipb_address ); switch ( $type ) { - case Block::TYPE_USER: - case Block::TYPE_IP: + case DatabaseBlock::TYPE_USER: + case DatabaseBlock::TYPE_IP: $formatted = Linker::userLink( $target->getId(), $target ); $formatted .= Linker::userToolLinks( $target->getId(), @@ -119,7 +120,7 @@ class BlockListPager extends TablePager { Linker::TOOL_LINKS_NOBLOCK ); break; - case Block::TYPE_RANGE: + case DatabaseBlock::TYPE_RANGE: $formatted = htmlspecialchars( $target ); } } @@ -249,6 +250,7 @@ class BlockListPager extends TablePager { */ private function getRestrictionListHTML( stdClass $row ) { $items = []; + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); foreach ( $this->restrictions as $restriction ) { if ( $restriction->getBlockId() !== (int)$row->ipb_id ) { @@ -261,20 +263,20 @@ class BlockListPager extends TablePager { $items[$restriction->getType()][] = Html::rawElement( 'li', [], - Linker::link( $restriction->getTitle() ) + $linkRenderer->makeLink( $restriction->getTitle() ) ); } break; case NamespaceRestriction::TYPE: $text = $restriction->getValue() === NS_MAIN - ? $this->msg( 'blanknamespace' ) + ? $this->msg( 'blanknamespace' )->text() : $this->getLanguage()->getFormattedNsText( $restriction->getValue() ); $items[$restriction->getType()][] = Html::rawElement( 'li', [], - Linker::link( + $linkRenderer->makeLink( SpecialPage::getTitleValueFor( 'Allpages' ), $text, [], @@ -363,7 +365,7 @@ class BlockListPager extends TablePager { function getTotalAutoblocks() { $dbr = $this->getDatabase(); $res = $dbr->selectField( 'ipblocks', - [ 'COUNT(*) AS totalautoblocks' ], + 'COUNT(*)', [ 'ipb_auto' => '1', 'ipb_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ), diff --git a/includes/specials/pagers/ContribsPager.php b/includes/specials/pagers/ContribsPager.php index e0db715925..d82ba535a6 100644 --- a/includes/specials/pagers/ContribsPager.php +++ b/includes/specials/pagers/ContribsPager.php @@ -386,7 +386,7 @@ class ContribsPager extends RangeChronologicalPager { } $associatedNS = $this->mDb->addQuotes( - MediaWikiServices::getInstance()->getAssociated( $this->namespace ) + MediaWikiServices::getInstance()->getNamespaceInfo()->getAssociated( $this->namespace ) ); return [ diff --git a/includes/specials/pagers/DeletedContribsPager.php b/includes/specials/pagers/DeletedContribsPager.php index 56b799bb7c..11a8532092 100644 --- a/includes/specials/pagers/DeletedContribsPager.php +++ b/includes/specials/pagers/DeletedContribsPager.php @@ -50,7 +50,7 @@ class DeletedContribsPager extends IndexPager { public $namespace = ''; /** - * @var \Wikimedia\Rdbms\Database + * @var IDatabase */ public $mDb; diff --git a/includes/specials/pagers/ImageListPager.php b/includes/specials/pagers/ImageListPager.php index ea55568fd2..2d3b6b291f 100644 --- a/includes/specials/pagers/ImageListPager.php +++ b/includes/specials/pagers/ImageListPager.php @@ -436,7 +436,8 @@ class ImageListPager extends TablePager { * @throws MWException */ function formatValue( $field, $value ) { - $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $services = MediaWikiServices::getInstance(); + $linkRenderer = $services->getLinkRenderer(); switch ( $field ) { case 'thumb': $opt = [ 'time' => wfTimestamp( TS_MW, $this->mCurrentRow->img_timestamp ) ]; @@ -468,15 +469,18 @@ class ImageListPager extends TablePager { $filePage, $filePage->getText() ); - $download = Xml::element( 'a', - [ 'href' => wfLocalFile( $filePage )->getUrl() ], + $download = Xml::element( + 'a', + [ 'href' => $services->getRepoGroup()->getLocalRepo()->newFile( $filePage )->getUrl() ], $imgfile ); $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped(); // Add delete links if allowed // From https://github.com/Wikia/app/pull/3859 - if ( $filePage->userCan( 'delete', $this->getUser() ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + if ( $permissionManager->userCan( 'delete', $this->getUser(), $filePage ) ) { $deleteMsg = $this->msg( 'listfiles-delete' )->text(); $delete = $linkRenderer->makeKnownLink( @@ -513,7 +517,7 @@ class ImageListPager extends TablePager { return $this->getLanguage()->formatNum( intval( $value ) + 1 ); case 'top': // Messages: listfiles-latestversion-yes, listfiles-latestversion-no - return $this->msg( 'listfiles-latestversion-' . $value ); + return $this->msg( 'listfiles-latestversion-' . $value )->escaped(); default: throw new MWException( "Unknown field '$field'" ); } diff --git a/includes/specials/pagers/UsersPager.php b/includes/specials/pagers/UsersPager.php index 4453772fb2..57b575b8ec 100644 --- a/includes/specials/pagers/UsersPager.php +++ b/includes/specials/pagers/UsersPager.php @@ -49,7 +49,7 @@ class UsersPager extends AlphabeticPager { } $request = $this->getRequest(); - $par = ( $par !== null ) ? $par : ''; + $par = $par ?? ''; $parms = explode( '/', $par ); $symsForAll = [ '*', 'user' ]; @@ -277,7 +277,7 @@ class UsersPager extends AlphabeticPager { * @return string */ function getPageHeader() { - list( $self ) = explode( '/', $this->getTitle()->getPrefixedDBkey() ); + $self = explode( '/', $this->getTitle()->getPrefixedDBkey(), 2 )[0]; $groupOptions = [ $this->msg( 'group-all' )->text() => '' ]; foreach ( $this->getAllGroups() as $group => $groupText ) { diff --git a/includes/tidy/RemexCompatMunger.php b/includes/tidy/RemexCompatMunger.php index 0cc9905823..a37f4f76f0 100644 --- a/includes/tidy/RemexCompatMunger.php +++ b/includes/tidy/RemexCompatMunger.php @@ -482,9 +482,8 @@ class RemexCompatMunger implements TreeHandler { } public function comment( $preposition, $refElement, $text, $sourceStart, $sourceLength ) { - list( $parent, $refNode ) = $this->getParentForInsert( $preposition, $refElement ); - $this->serializer->comment( $preposition, $refNode, $text, - $sourceStart, $sourceLength ); + list( , $refNode ) = $this->getParentForInsert( $preposition, $refElement ); + $this->serializer->comment( $preposition, $refNode, $text, $sourceStart, $sourceLength ); } public function error( $text, $pos ) { diff --git a/includes/tidy/RemexMungerData.php b/includes/tidy/RemexMungerData.php index 08d148f682..c0dd00b5e7 100644 --- a/includes/tidy/RemexMungerData.php +++ b/includes/tidy/RemexMungerData.php @@ -83,6 +83,8 @@ class RemexMungerData { * @return string */ public function dump() { + $parts = []; + if ( $this->childPElement ) { $parts[] = 'childPElement=' . $this->childPElement->getDebugTag(); } diff --git a/includes/title/MediaWikiTitleCodec.php b/includes/title/MediaWikiTitleCodec.php index 778fb3f033..5021a1c9fe 100644 --- a/includes/title/MediaWikiTitleCodec.php +++ b/includes/title/MediaWikiTitleCodec.php @@ -283,7 +283,7 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser { # Strip Unicode bidi override characters. # Sometimes they slip into cut-n-pasted page titles, where the # override chars get included in list displays. - $dbkey = preg_replace( '/\xE2\x80[\x8E\x8F\xAA-\xAE]/S', '', $dbkey ); + $dbkey = preg_replace( '/[\x{200E}\x{200F}\x{202A}-\x{202E}]+/u', '', $dbkey ); # Clean up whitespace # Note: use of the /u option on preg_replace here will cause diff --git a/includes/title/NamespaceInfo.php b/includes/title/NamespaceInfo.php index 7cfadc0144..2ed8729ac6 100644 --- a/includes/title/NamespaceInfo.php +++ b/includes/title/NamespaceInfo.php @@ -142,6 +142,8 @@ class NamespaceInfo { * * @param int $index Namespace index * @return int + * @throws MWException if the given namespace doesn't have an associated talk namespace + * (e.g. NS_SPECIAL). */ public function getTalk( $index ) { $this->isMethodValidFor( $index, __METHOD__ ); @@ -151,15 +153,52 @@ class NamespaceInfo { } /** + * Get a LinkTarget referring to the talk page of $target. + * + * @see canHaveTalkPage * @param LinkTarget $target * @return LinkTarget Talk page for $target - * @throws MWException if $target's namespace doesn't have talk pages (e.g., NS_SPECIAL) + * @throws MWException if $target doesn't have talk pages, e.g. because it's in NS_SPECIAL, + * because it's a relative section-only link, or it's an an interwiki link. */ public function getTalkPage( LinkTarget $target ) : LinkTarget { + if ( $target->getText() === '' ) { + throw new MWException( 'Can\'t determine talk page associated with relative section link' ); + } + + if ( $target->getInterwiki() !== '' ) { + throw new MWException( 'Can\'t determine talk page associated with interwiki link' ); + } + if ( $this->isTalk( $target->getNamespace() ) ) { return $target; } - return new TitleValue( $this->getTalk( $target->getNamespace() ), $target->getDbKey() ); + + // NOTE: getTalk throws on bad namespaces! + return new TitleValue( $this->getTalk( $target->getNamespace() ), $target->getDBkey() ); + } + + /** + * Can the title have a corresponding talk page? + * + * False for relative section-only links (with getText() === ''), + * interwiki links (with getInterwiki() !== ''), and pages in NS_SPECIAL. + * + * @see getTalkPage + * + * @param LinkTarget $target + * @return bool True if this title either is a talk page or can have a talk page associated. + */ + public function canHaveTalkPage( LinkTarget $target ) { + if ( $target->getText() === '' || $target->getInterwiki() !== '' ) { + return false; + } + + if ( $target->getNamespace() < NS_MAIN ) { + return false; + } + + return true; } /** @@ -188,7 +227,7 @@ class NamespaceInfo { if ( $this->isSubject( $target->getNamespace() ) ) { return $target; } - return new TitleValue( $this->getSubject( $target->getNamespace() ), $target->getDbKey() ); + return new TitleValue( $this->getSubject( $target->getNamespace() ), $target->getDBkey() ); } /** @@ -216,8 +255,16 @@ class NamespaceInfo { * @throws MWException if $target's namespace doesn't have talk pages (e.g., NS_SPECIAL) */ public function getAssociatedPage( LinkTarget $target ) : LinkTarget { + if ( $target->getText() === '' ) { + throw new MWException( 'Can\'t determine talk page associated with relative section link' ); + } + + if ( $target->getInterwiki() !== '' ) { + throw new MWException( 'Can\'t determine talk page associated with interwiki link' ); + } + return new TitleValue( - $this->getAssociated( $target->getNamespace() ), $target->getDbKey() ); + $this->getAssociated( $target->getNamespace() ), $target->getDBkey() ); } /** @@ -524,9 +571,15 @@ class NamespaceInfo { return $levels; } - // First, get the list of groups that can edit this namespace. - $namespaceGroups = []; - $combine = 'array_merge'; + // $wgNamespaceProtection can require one or more rights to edit the namespace, which + // may be satisfied by membership in multiple groups each giving a subset of those rights. + // A restriction level is redundant if, for any one of the namespace rights, all groups + // giving that right also give the restriction level's right. Or, conversely, a + // restriction level is not redundant if, for every namespace right, there's at least one + // group giving that right without the restriction level's right. + // + // First, for each right, get a list of groups with that right. + $namespaceRightGroups = []; foreach ( (array)$this->options->get( 'NamespaceProtection' )[$index] as $right ) { if ( $right == 'sysop' ) { $right = 'editprotected'; // BC @@ -535,15 +588,11 @@ class NamespaceInfo { $right = 'editsemiprotected'; // BC } if ( $right != '' ) { - $namespaceGroups = call_user_func( $combine, $namespaceGroups, - User::getGroupsWithPermission( $right ) ); - $combine = 'array_intersect'; + $namespaceRightGroups[$right] = User::getGroupsWithPermission( $right ); } } - // Now, keep only those restriction levels where there is at least one - // group that can edit the namespace but would be blocked by the - // restriction. + // Now, go through the protection levels one by one. $usableLevels = [ '' ]; foreach ( $this->options->get( 'RestrictionLevels' ) as $level ) { $right = $level; @@ -553,9 +602,19 @@ class NamespaceInfo { if ( $right == 'autoconfirmed' ) { $right = 'editsemiprotected'; // BC } - if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) && - array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) ) + + if ( $right != '' && + !isset( $namespaceRightGroups[$right] ) && + ( !$user || $user->isAllowed( $right ) ) ) { + // Do any of the namespace rights imply the restriction right? (see explanation above) + foreach ( $namespaceRightGroups as $groups ) { + if ( !array_diff( $groups, User::getGroupsWithPermission( $right ) ) ) { + // Yes, this one does. + continue 2; + } + } + // No, keep the restriction level $usableLevels[] = $level; } } diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index 2bbe7c36e9..ae5b73249d 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -404,7 +404,7 @@ abstract class UploadBase { * @return mixed True if the file is verified, an array otherwise */ protected function verifyMimeType( $mime ) { - global $wgVerifyMimeType; + global $wgVerifyMimeType, $wgVerifyMimeTypeIE; if ( $wgVerifyMimeType ) { wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>\n" ); global $wgMimeTypeBlacklist; @@ -412,17 +412,19 @@ abstract class UploadBase { return [ 'filetype-badmime', $mime ]; } - # Check what Internet Explorer would detect - $fp = fopen( $this->mTempPath, 'rb' ); - $chunk = fread( $fp, 256 ); - fclose( $fp ); - - $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); - $extMime = $magic->guessTypesForExtension( $this->mFinalExtension ); - $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime ); - foreach ( $ieTypes as $ieType ) { - if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) { - return [ 'filetype-bad-ie-mime', $ieType ]; + if ( $wgVerifyMimeTypeIE ) { + # Check what Internet Explorer would detect + $fp = fopen( $this->mTempPath, 'rb' ); + $chunk = fread( $fp, 256 ); + fclose( $fp ); + + $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); + $extMime = $magic->guessTypesForExtension( $this->mFinalExtension ); + $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime ); + foreach ( $ieTypes as $ieType ) { + if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) { + return [ 'filetype-bad-ie-mime', $ieType ]; + } } } } @@ -1195,7 +1197,7 @@ abstract class UploadBase { * * @param string[] $ext * @param string[] $list - * @return bool + * @return string[] */ public static function checkFileExtensionList( $ext, $list ) { return array_intersect( array_map( 'strtolower', $ext ), $list ); @@ -1262,12 +1264,11 @@ abstract class UploadBase { * @return bool True if the file contains something looking like embedded scripts */ public static function detectScript( $file, $mime, $extension ) { - global $wgAllowTitlesInSVG; - # ugly hack: for text files, always look at the entire file. # For binary field, just check the first K. - if ( strpos( $mime, 'text/' ) === 0 ) { + $isText = strpos( $mime, 'text/' ) === 0; + if ( $isText ) { $chunk = file_get_contents( $file ); } else { $fp = fopen( $file, 'rb' ); @@ -1312,36 +1313,19 @@ abstract class UploadBase { } } - /** - * Internet Explorer for Windows performs some really stupid file type - * autodetection which can cause it to interpret valid image files as HTML - * and potentially execute JavaScript, creating a cross-site scripting - * attack vectors. - * - * Apple's Safari browser also performs some unsafe file type autodetection - * which can cause legitimate files to be interpreted as HTML if the - * web server is not correctly configured to send the right content-type - * (or if you're really uploading plain text and octet streams!) - * - * Returns true if IE is likely to mistake the given file for HTML. - * Also returns true if Safari would mistake the given file for HTML - * when served with a generic content-type. - */ + // Quick check for HTML heuristics in old IE and Safari. + // + // The exact heuristics IE uses are checked separately via verifyMimeType(), so we + // don't need them all here as it can cause many false positives. + // + // Check for `mSVGNSError = false; @@ -1478,7 +1462,7 @@ abstract class UploadBase { * Callback to filter SVG Processing Instructions. * @param string $target Processing instruction name * @param string $data Processing instruction attribute and value - * @return bool (true if the filter identified something bad) + * @return bool|array */ public static function checkSvgPICallback( $target, $data ) { // Don't allow external stylesheets (T59550) @@ -1525,7 +1509,7 @@ abstract class UploadBase { * @param string $element * @param array $attribs * @param array|null $data - * @return bool + * @return bool|array */ public function checkSvgScriptCallback( $element, $attribs, $data = null ) { list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element ); @@ -1836,7 +1820,7 @@ abstract class UploadBase { * $wgAntivirusRequired may be used to deny upload if the scan fails. * * @param string $file Pathname to the temporary upload file - * @return mixed False if not virus is found, null if the scan fails or is disabled, + * @return bool|null|string False if not virus is found, null if the scan fails or is disabled, * or a string containing feedback from the virus scanner if a virus was found. * If textual feedback is missing but a virus was found, this function returns true. */ @@ -1932,7 +1916,7 @@ abstract class UploadBase { * * @param User $user * - * @return mixed True on success, array on failure + * @return bool|array */ private function checkOverwrite( $user ) { // First check whether the local file can be overwritten @@ -2085,10 +2069,10 @@ abstract class UploadBase { $partname = $n ? substr( $filename, 0, $n ) : $filename; return ( - substr( $partname, 3, 3 ) == 'px-' || - substr( $partname, 2, 3 ) == 'px-' - ) && - preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) ); + substr( $partname, 3, 3 ) == 'px-' || + substr( $partname, 2, 3 ) == 'px-' + ) && + preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) ); } /** diff --git a/includes/upload/UploadStash.php b/includes/upload/UploadStash.php index d39975db1b..215bd2041f 100644 --- a/includes/upload/UploadStash.php +++ b/includes/upload/UploadStash.php @@ -433,7 +433,7 @@ class UploadStash { * List all files in the stash. * * @throws UploadStashNotLoggedInException - * @return array + * @return array|false */ public function listFiles() { if ( !$this->isLoggedIn ) { diff --git a/includes/upload/exception/UploadChunkVerificationException.php b/includes/upload/exception/UploadChunkVerificationException.php index cee8c030d6..d7733abd4e 100644 --- a/includes/upload/exception/UploadChunkVerificationException.php +++ b/includes/upload/exception/UploadChunkVerificationException.php @@ -23,6 +23,7 @@ class UploadChunkVerificationException extends MWException { public $msg; + public function __construct( array $res ) { $this->msg = wfMessage( ...$res ); parent::__construct( wfMessage( ...$res ) diff --git a/includes/user/BotPassword.php b/includes/user/BotPassword.php index 6db219deec..df5edef2f3 100644 --- a/includes/user/BotPassword.php +++ b/includes/user/BotPassword.php @@ -21,7 +21,7 @@ use MediaWiki\Auth\AuthenticationResponse; use MediaWiki\MediaWikiServices; use MediaWiki\Session\BotPasswordSessionProvider; -use Wikimedia\Rdbms\IMaintainableDatabase; +use Wikimedia\Rdbms\IDatabase; /** * Utility class for bot passwords @@ -71,7 +71,7 @@ class BotPassword implements IDBAccessObject { /** * Get a database connection for the bot passwords database * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA. - * @return IMaintainableDatabase + * @return IDatabase */ public static function getDB( $db ) { global $wgBotPasswordsCluster, $wgBotPasswordsDatabase; diff --git a/includes/user/User.php b/includes/user/User.php index 2f6deb5f48..84298e2d16 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -21,6 +21,7 @@ */ use MediaWiki\Block\AbstractBlock; +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\SystemBlock; use MediaWiki\MediaWikiServices; use MediaWiki\Session\SessionManager; @@ -86,6 +87,7 @@ class User implements IDBAccessObject, UserIdentity { * shared cache (memcached). Any operation which changes the * corresponding database fields must call a cache-clearing function. * @showinitializer + * @var string[] */ protected static $mCacheVars = [ // user table @@ -109,95 +111,8 @@ class User implements IDBAccessObject, UserIdentity { ]; /** - * Array of Strings Core rights. - * Each of these should have a corresponding message of the form - * "right-$right". - * @showinitializer - */ - protected static $mCoreRights = [ - 'apihighlimits', - 'applychangetags', - 'autoconfirmed', - 'autocreateaccount', - 'autopatrol', - 'bigdelete', - 'block', - 'blockemail', - 'bot', - 'browsearchive', - 'changetags', - 'createaccount', - 'createpage', - 'createtalk', - 'delete', - 'deletechangetags', - 'deletedhistory', - 'deletedtext', - 'deletelogentry', - 'deleterevision', - 'edit', - 'editcontentmodel', - 'editinterface', - 'editprotected', - 'editmyoptions', - 'editmyprivateinfo', - 'editmyusercss', - 'editmyuserjson', - 'editmyuserjs', - 'editmywatchlist', - 'editsemiprotected', - 'editsitecss', - 'editsitejson', - 'editsitejs', - 'editusercss', - 'edituserjson', - 'edituserjs', - 'hideuser', - 'import', - 'importupload', - 'ipblock-exempt', - 'managechangetags', - 'markbotedits', - 'mergehistory', - 'minoredit', - 'move', - 'movefile', - 'move-categorypages', - 'move-rootuserpages', - 'move-subpages', - 'nominornewtalk', - 'noratelimit', - 'override-export-depth', - 'pagelang', - 'patrol', - 'patrolmarks', - 'protect', - 'purge', - 'read', - 'reupload', - 'reupload-own', - 'reupload-shared', - 'rollback', - 'sendemail', - 'siteadmin', - 'suppressionlog', - 'suppressredirect', - 'suppressrevision', - 'unblockself', - 'undelete', - 'unwatchedpages', - 'upload', - 'upload_by_url', - 'userrights', - 'userrights-interwiki', - 'viewmyprivateinfo', - 'viewmywatchlist', - 'viewsuppressed', - 'writeapi', - ]; - - /** - * String Cached results of getAllRights() + * @var string[] + * @var string[] Cached results of getAllRights() */ protected static $mAllRights = false; @@ -236,20 +151,20 @@ class User implements IDBAccessObject, UserIdentity { protected $mOptionOverrides; // @} + // @{ /** - * Bool Whether the cache variables have been loaded. + * @var bool Whether the cache variables have been loaded. */ - // @{ public $mOptionsLoaded; /** - * Array with already loaded items or true if all items have been loaded. + * @var array|bool Array with already loaded items or true if all items have been loaded. */ protected $mLoadedItems = []; // @} /** - * String Initialization data source if mLoadedItems!==true. May be one of: + * @var string Initialization data source if mLoadedItems!==true. May be one of: * - 'defaults' anonymous user initialised from class defaults * - 'name' initialise from mName * - 'id' initialise from mId @@ -263,6 +178,7 @@ class User implements IDBAccessObject, UserIdentity { /** * Lazy-initialized variables, invalidated with clearInstanceCache */ + /** @var int|bool */ protected $mNewtalk; /** @var string */ protected $mDatePreference; @@ -270,8 +186,6 @@ class User implements IDBAccessObject, UserIdentity { public $mBlockedby; /** @var string */ protected $mHash; - /** @var array */ - public $mRights; /** @var string */ protected $mBlockreason; /** @var array */ @@ -298,12 +212,13 @@ class User implements IDBAccessObject, UserIdentity { /** @var bool */ protected $mAllowUsertalk; - /** @var AbstractBlock */ + /** @var AbstractBlock|bool */ private $mBlockedFromCreateAccount = false; /** @var int User::READ_* constant bitfield used to load data */ protected $queryFlagsUsed = self::READ_NORMAL; + /** @var int[] */ public static $idCacheByName = []; /** @@ -328,6 +243,24 @@ class User implements IDBAccessObject, UserIdentity { return (string)$this->getName(); } + public function __get( $name ) { + // A shortcut for $mRights deprecation phase + if ( $name === 'mRights' ) { + return $this->getRights(); + } + } + + public function __set( $name, $value ) { + // A shortcut for $mRights deprecation phase, only known legitimate use was for + // testing purposes, other uses seem bad in principle + if ( $name === 'mRights' ) { + MediaWikiServices::getInstance()->getPermissionManager()->overrideUserRightsForTesting( + $this, + is_null( $value ) ? [] : $value + ); + } + } + /** * Test if it's safe to load this User object. * @@ -486,12 +419,12 @@ class User implements IDBAccessObject, UserIdentity { /** * @since 1.27 - * @param string $wikiId + * @param string $dbDomain * @param int $userId */ - public static function purge( $wikiId, $userId ) { + public static function purge( $dbDomain, $userId ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); - $key = $cache->makeGlobalKey( 'user', 'id', $wikiId, $userId ); + $key = $cache->makeGlobalKey( 'user', 'id', $dbDomain, $userId ); $cache->delete( $key ); } @@ -675,16 +608,16 @@ class User implements IDBAccessObject, UserIdentity { * @param int|null $userId User ID, if known * @param string|null $userName User name, if known * @param int|null $actorId Actor ID, if known - * @param bool|string $wikiId remote wiki to which the User/Actor ID applies, or false if none + * @param bool|string $dbDomain remote wiki to which the User/Actor ID applies, or false if none * @return User */ - public static function newFromAnyId( $userId, $userName, $actorId, $wikiId = false ) { + public static function newFromAnyId( $userId, $userName, $actorId, $dbDomain = false ) { global $wgActorTableSchemaMigrationStage; // Stop-gap solution for the problem described in T222212. // Force the User ID and Actor ID to zero for users loaded from the database // of another wiki, to prevent subtle data corruption and confusing failure modes. - if ( $wikiId !== false ) { + if ( $dbDomain !== false ) { $userId = 0; $actorId = 0; } @@ -945,12 +878,12 @@ class User implements IDBAccessObject, UserIdentity { $result = (int)$s->user_id; } - self::$idCacheByName[$name] = $result; - - if ( count( self::$idCacheByName ) > 1000 ) { + if ( count( self::$idCacheByName ) >= 1000 ) { self::$idCacheByName = []; } + self::$idCacheByName[$name] = $result; + return $result; } @@ -1162,35 +1095,6 @@ class User implements IDBAccessObject, UserIdentity { return $this->checkPasswordValidity( $password )->isGood(); } - /** - * Given unvalidated password input, return error message on failure. - * - * @param string $password Desired password - * @return bool|string|array True on success, string or array of error message on failure - * @deprecated since 1.33, use checkPasswordValidity - */ - public function getPasswordValidity( $password ) { - wfDeprecated( __METHOD__, '1.33' ); - - $result = $this->checkPasswordValidity( $password ); - if ( $result->isGood() ) { - return true; - } - - $messages = []; - foreach ( $result->getErrorsByType( 'error' ) as $error ) { - $messages[] = $error['message']; - } - foreach ( $result->getErrorsByType( 'warning' ) as $warning ) { - $messages[] = $warning['message']; - } - if ( count( $messages ) === 1 ) { - return $messages[0]; - } - - return $messages; - } - /** * Check if this is a valid password for this user * @@ -1370,25 +1274,17 @@ class User implements IDBAccessObject, UserIdentity { * @return bool True if the user is logged in, false otherwise. */ private function loadFromSession() { - // Deprecated hook - $result = null; - Hooks::run( 'UserLoadFromSession', [ $this, &$result ], '1.27' ); - if ( $result !== null ) { - return $result; - } - // MediaWiki\Session\Session already did the necessary authentication of the user // returned here, so just use it if applicable. $session = $this->getRequest()->getSession(); $user = $session->getUser(); if ( $user->isLoggedIn() ) { $this->loadFromUserObject( $user ); - if ( $user->getBlock() ) { - // If this user is autoblocked, set a cookie to track the Block. This has to be done on - // every session load, because an autoblocked editor might not edit again from the same - // IP address after being blocked. - $this->trackBlockWithCookie(); - } + + // If this user is autoblocked, set a cookie to track the block. This has to be done on + // every session load, because an autoblocked editor might not edit again from the same + // IP address after being blocked. + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $this ); // Other code expects these to be set in the session, so set them. $session->set( 'wsUserID', $this->getId() ); @@ -1403,15 +1299,11 @@ class User implements IDBAccessObject, UserIdentity { /** * Set the 'BlockID' cookie depending on block type and user authentication status. + * + * @deprecated since 1.34 Use BlockManager::trackBlockWithCookie instead */ public function trackBlockWithCookie() { - $block = $this->getBlock(); - - if ( $block && $this->getRequest()->getCookie( 'BlockID' ) === null - && $block->shouldTrackWithCookie( $this->isAnon() ) - ) { - $block->setCookie( $this->getRequest()->response() ); - } + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $this ); } /** @@ -1734,11 +1626,12 @@ class User implements IDBAccessObject, UserIdentity { * given source. May be "name", "id", "actor", "defaults", "session", or false for no reload. */ public function clearInstanceCache( $reloadFrom = false ) { + global $wgFullyInitialised; + $this->mNewtalk = -1; $this->mDatePreference = null; $this->mBlockedby = -1; # Unset $this->mHash = false; - $this->mRights = null; $this->mEffectiveGroups = null; $this->mImplicitGroups = null; $this->mGroupMemberships = null; @@ -1746,6 +1639,13 @@ class User implements IDBAccessObject, UserIdentity { $this->mOptionsLoaded = false; $this->mEditCount = null; + // Replacement of former `$this->mRights = null` line + if ( $wgFullyInitialised && $this->mFrom ) { + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( + $this + ); + } + if ( $reloadFrom ) { $this->mLoadedItems = []; $this->mFrom = $reloadFrom; @@ -1850,8 +1750,7 @@ class User implements IDBAccessObject, UserIdentity { $fromReplica ); - if ( $block instanceof AbstractBlock ) { - wfDebug( __METHOD__ . ": Found block.\n" ); + if ( $block ) { $this->mBlock = $block; $this->mBlockedby = $block->getByName(); $this->mBlockreason = $block->getReason(); @@ -2185,7 +2084,6 @@ class User implements IDBAccessObject, UserIdentity { * @param Title $title Title to check * @param bool $fromReplica Whether to check the replica DB instead of the master * @return bool - * @throws MWException * * @deprecated since 1.33, * use MediaWikiServices::getInstance()->getPermissionManager()->isBlockedFrom(..) @@ -2573,7 +2471,7 @@ class User implements IDBAccessObject, UserIdentity { $dbw->insert( 'user_newtalk', [ $field => $id, 'user_last_timestamp' => $dbw->timestampOrNull( $ts ) ], __METHOD__, - 'IGNORE' ); + [ 'IGNORE' ] ); if ( $dbw->affectedRows() ) { wfDebug( __METHOD__ . ": set on ($field, $id)\n" ); return true; @@ -3326,7 +3224,7 @@ class User implements IDBAccessObject, UserIdentity { * and 'all', which forces a reset of *all* preferences and overrides everything else. * * @param array|string $resetKinds Which kinds of preferences to reset. Defaults to - * array( 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ) + * [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ] * for backwards-compatibility. * @param IContextSource|null $context Context source used when $resetKinds * does not contain 'all', passed to getOptionKinds(). @@ -3431,44 +3329,13 @@ class User implements IDBAccessObject, UserIdentity { /** * Get the permissions this user has. * @return string[] permission names + * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getUserPermissions(..) instead + * */ public function getRights() { - if ( is_null( $this->mRights ) ) { - $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() ); - Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] ); - - // Deny any rights denied by the user's session, unless this - // endpoint has no sessions. - if ( !defined( 'MW_NO_SESSION' ) ) { - $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights(); - if ( $allowedRights !== null ) { - $this->mRights = array_intersect( $this->mRights, $allowedRights ); - } - } - - Hooks::run( 'UserGetRightsRemove', [ $this, &$this->mRights ] ); - // Force reindexation of rights when a hook has unset one of them - $this->mRights = array_values( array_unique( $this->mRights ) ); - - // If block disables login, we should also remove any - // extra rights blocked users might have, in case the - // blocked user has a pre-existing session (T129738). - // This is checked here for cases where people only call - // $user->isAllowed(). It is also checked in Title::checkUserBlock() - // to give a better error message in the common case. - $config = RequestContext::getMain()->getConfig(); - // @TODO Partial blocks should not prevent the user from logging in. - // see: https://phabricator.wikimedia.org/T208895 - if ( - $this->isLoggedIn() && - $config->get( 'BlockDisablesLogin' ) && - $this->getBlock() - ) { - $anon = new User; - $this->mRights = array_intersect( $this->mRights, $anon->getRights() ); - } - } - return $this->mRights; + return MediaWikiServices::getInstance()->getPermissionManager()->getUserPermissions( $this ); } /** @@ -3596,7 +3463,7 @@ class User implements IDBAccessObject, UserIdentity { if ( $count === null ) { // it has not been initialized. do so. - $count = $this->initEditCountInternal(); + $count = $this->initEditCountInternal( $dbr ); } $this->mEditCount = $count; } @@ -3637,8 +3504,7 @@ class User implements IDBAccessObject, UserIdentity { // Refresh the groups caches, and clear the rights cache so it will be // refreshed on the next call to $this->getRights(). $this->getEffectiveGroups( true ); - $this->mRights = null; - + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this ); $this->invalidateCache(); return true; @@ -3669,8 +3535,7 @@ class User implements IDBAccessObject, UserIdentity { // Refresh the groups caches, and clear the rights cache so it will be // refreshed on the next call to $this->getRights(). $this->getEffectiveGroups( true ); - $this->mRights = null; - + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache( $this ); $this->invalidateCache(); return true; @@ -3753,16 +3618,17 @@ class User implements IDBAccessObject, UserIdentity { /** * Internal mechanics of testing a permission + * + * @deprecated since 1.34, use MediaWikiServices::getInstance() + * ->getPermissionManager()->userHasRight(...) instead + * * @param string $action + * * @return bool */ public function isAllowed( $action = '' ) { - if ( $action === '' ) { - return true; // In the spirit of DWIM - } - // Use strict parameter to avoid matching numeric 0 accidentally inserted - // by misconfiguration: 0 == 'foo' - return in_array( $action, $this->getRights(), true ); + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $this, $action ); } /** @@ -4384,7 +4250,7 @@ class User implements IDBAccessObject, UserIdentity { return false; } - $userblock = Block::newFromTarget( $this->getName() ); + $userblock = DatabaseBlock::newFromTarget( $this->getName() ); if ( !$userblock ) { return false; } @@ -4406,7 +4272,9 @@ class User implements IDBAccessObject, UserIdentity { # blocked with createaccount disabled, prevent new account creation there even # when the user is logged in if ( $this->mBlockedFromCreateAccount === false && !$this->isAllowed( 'ipblock-exempt' ) ) { - $this->mBlockedFromCreateAccount = Block::newFromTarget( null, $this->getRequest()->getIP() ); + $this->mBlockedFromCreateAccount = DatabaseBlock::newFromTarget( + null, $this->getRequest()->getIP() + ); } return $this->mBlockedFromCreateAccount instanceof AbstractBlock && $this->mBlockedFromCreateAccount->appliesToRight( 'createaccount' ) @@ -4909,45 +4777,27 @@ class User implements IDBAccessObject, UserIdentity { /** * Get the permissions associated with a given list of groups * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getGroupPermissions() instead + * * @param array $groups Array of Strings List of internal group names * @return array Array of Strings List of permission key names for given groups combined */ public static function getGroupPermissions( $groups ) { - global $wgGroupPermissions, $wgRevokePermissions; - $rights = []; - // grant every granted permission first - foreach ( $groups as $group ) { - if ( isset( $wgGroupPermissions[$group] ) ) { - $rights = array_merge( $rights, - // array_filter removes empty items - array_keys( array_filter( $wgGroupPermissions[$group] ) ) ); - } - } - // now revoke the revoked permissions - foreach ( $groups as $group ) { - if ( isset( $wgRevokePermissions[$group] ) ) { - $rights = array_diff( $rights, - array_keys( array_filter( $wgRevokePermissions[$group] ) ) ); - } - } - return array_unique( $rights ); + return MediaWikiServices::getInstance()->getPermissionManager()->getGroupPermissions( $groups ); } /** * Get all the groups who have a given permission * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getGroupsWithPermission() instead + * * @param string $role Role to check * @return array Array of Strings List of internal group names with the given permission */ public static function getGroupsWithPermission( $role ) { - global $wgGroupPermissions; - $allowedGroups = []; - foreach ( array_keys( $wgGroupPermissions ) as $group ) { - if ( self::groupHasPermission( $group, $role ) ) { - $allowedGroups[] = $group; - } - } - return $allowedGroups; + return MediaWikiServices::getInstance()->getPermissionManager()->getGroupsWithPermission( $role ); } /** @@ -4957,15 +4807,17 @@ class User implements IDBAccessObject, UserIdentity { * User::isEveryoneAllowed() instead. That properly checks if it's revoked * from anyone. * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->groupHasPermission(..) instead + * * @since 1.21 * @param string $group Group to check * @param string $role Role to check * @return bool */ public static function groupHasPermission( $group, $role ) { - global $wgGroupPermissions, $wgRevokePermissions; - return isset( $wgGroupPermissions[$group][$role] ) && $wgGroupPermissions[$group][$role] - && !( isset( $wgRevokePermissions[$group][$role] ) && $wgRevokePermissions[$group][$role] ); + return MediaWikiServices::getInstance()->getPermissionManager() + ->groupHasPermission( $group, $role ); } /** @@ -4978,51 +4830,16 @@ class User implements IDBAccessObject, UserIdentity { * Specifically, session-based rights restrictions (such as OAuth or bot * passwords) are applied based on the current session. * - * @since 1.22 + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->isEveryoneAllowed() instead + * * @param string $right Right to check + * * @return bool + * @since 1.22 */ public static function isEveryoneAllowed( $right ) { - global $wgGroupPermissions, $wgRevokePermissions; - static $cache = []; - - // Use the cached results, except in unit tests which rely on - // being able change the permission mid-request - if ( isset( $cache[$right] ) && !defined( 'MW_PHPUNIT_TEST' ) ) { - return $cache[$right]; - } - - if ( !isset( $wgGroupPermissions['*'][$right] ) || !$wgGroupPermissions['*'][$right] ) { - $cache[$right] = false; - return false; - } - - // If it's revoked anywhere, then everyone doesn't have it - foreach ( $wgRevokePermissions as $rights ) { - if ( isset( $rights[$right] ) && $rights[$right] ) { - $cache[$right] = false; - return false; - } - } - - // Remove any rights that aren't allowed to the global-session user, - // unless there are no sessions for this endpoint. - if ( !defined( 'MW_NO_SESSION' ) ) { - $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights(); - if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) { - $cache[$right] = false; - return false; - } - } - - // Allow extensions to say false - if ( !Hooks::run( 'UserIsEveryoneAllowed', [ $right ] ) ) { - $cache[$right] = false; - return false; - } - - $cache[$right] = true; - return true; + return MediaWikiServices::getInstance()->getPermissionManager()->isEveryoneAllowed( $right ); } /** @@ -5041,19 +4858,14 @@ class User implements IDBAccessObject, UserIdentity { /** * Get a list of all available permissions. + * + * @deprecated since 1.34, use MediaWikiServices::getInstance()->getPermissionManager() + * ->getAllPermissions() instead + * * @return string[] Array of permission names */ public static function getAllRights() { - if ( self::$mAllRights === false ) { - global $wgAvailableRights; - if ( count( $wgAvailableRights ) ) { - self::$mAllRights = array_unique( array_merge( self::$mCoreRights, $wgAvailableRights ) ); - } else { - self::$mAllRights = self::$mCoreRights; - } - Hooks::run( 'UserGetAllRights', [ &self::$mAllRights ] ); - } - return self::$mAllRights; + return MediaWikiServices::getInstance()->getPermissionManager()->getAllPermissions(); } /** @@ -5071,10 +4883,10 @@ class User implements IDBAccessObject, UserIdentity { * Returns an array of the groups that a particular group can add/remove. * * @param string $group The group to check for whether it can add/remove - * @return array Array( 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self), - * 'remove-self' => array( removable groups from self) ) + * @return array [ 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] ] */ public static function changeableByGroup( $group ) { global $wgAddGroups, $wgRemoveGroups, $wgGroupsAddToSelf, $wgGroupsRemoveFromSelf; @@ -5144,10 +4956,10 @@ class User implements IDBAccessObject, UserIdentity { /** * Returns an array of groups that this user can add and remove - * @return array Array( 'add' => array( addablegroups ), - * 'remove' => array( removablegroups ), - * 'add-self' => array( addablegroups to self), - * 'remove-self' => array( removable groups from self) ) + * @return array [ 'add' => [ addablegroups ], + * 'remove' => [ removablegroups ], + * 'add-self' => [ addablegroups to self ], + * 'remove-self' => [ removable groups from self ] ] */ public function changeableGroups() { if ( $this->isAllowed( 'userrights' ) ) { @@ -5211,14 +5023,13 @@ class User implements IDBAccessObject, UserIdentity { /** * Initialize user_editcount from data out of the revision table * - * This method should not be called outside User/UserEditCountUpdate - * + * @internal This method should not be called outside User/UserEditCountUpdate + * @param IDatabase $dbr Replica database * @return int Number of edits */ - public function initEditCountInternal() { + public function initEditCountInternal( IDatabase $dbr ) { // Pull from a replica DB to be less cruel to servers // Accuracy isn't the point anyway here - $dbr = wfGetDB( DB_REPLICA ); $actorWhere = ActorMigration::newMigration()->getWhere( $dbr, 'rev_user', $this ); $count = (int)$dbr->selectField( [ 'revision' ] + $actorWhere['tables'], diff --git a/includes/user/UserArray.php b/includes/user/UserArray.php index 66d9c7a1f8..c398e4904e 100644 --- a/includes/user/UserArray.php +++ b/includes/user/UserArray.php @@ -32,10 +32,7 @@ abstract class UserArray implements Iterator { if ( !Hooks::run( 'UserArrayFromResult', [ &$userArray, $res ] ) ) { return null; } - if ( $userArray === null ) { - $userArray = self::newFromResult_internal( $res ); - } - return $userArray; + return $userArray ?? new UserArrayFromResult( $res ); } /** @@ -84,12 +81,4 @@ abstract class UserArray implements Iterator { ); return self::newFromResult( $res ); } - - /** - * @param IResultWrapper $res - * @return UserArrayFromResult - */ - protected static function newFromResult_internal( $res ) { - return new UserArrayFromResult( $res ); - } } diff --git a/includes/user/UserGroupMembership.php b/includes/user/UserGroupMembership.php index acd697081c..e06df9fd23 100644 --- a/includes/user/UserGroupMembership.php +++ b/includes/user/UserGroupMembership.php @@ -395,12 +395,13 @@ class UserGroupMembership { // link to the group description page, if it exists $linkTitle = self::getGroupPage( $group ); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); if ( $linkTitle ) { if ( $format === 'wiki' ) { $linkPage = $linkTitle->getFullText(); $groupLink = "[[$linkPage|$groupName]]"; } else { - $groupLink = Linker::link( $linkTitle, htmlspecialchars( $groupName ) ); + $groupLink = $linkRenderer->makeLink( $linkTitle, $groupName ); } } else { $groupLink = htmlspecialchars( $groupName ); diff --git a/includes/user/UserRightsProxy.php b/includes/user/UserRightsProxy.php index 0b7a7200f9..603d3339a8 100644 --- a/includes/user/UserRightsProxy.php +++ b/includes/user/UserRightsProxy.php @@ -30,7 +30,7 @@ class UserRightsProxy { /** @var IDatabase */ private $db; /** @var string */ - private $wikiId; + private $dbDomain; /** @var string */ private $name; /** @var int */ @@ -42,13 +42,13 @@ class UserRightsProxy { * @see newFromId() * @see newFromName() * @param IDatabase $db Db connection - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param string $name User name * @param int $id User ID */ - private function __construct( $db, $wikiId, $name, $id ) { + private function __construct( $db, $dbDomain, $name, $id ) { $this->db = $db; - $this->wikiId = $wikiId; + $this->dbDomain = $dbDomain; $this->name = $name; $this->id = intval( $id ); $this->newOptions = []; @@ -57,24 +57,24 @@ class UserRightsProxy { /** * Confirm the selected database name is a valid local interwiki database name. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @return bool */ - public static function validDatabase( $wikiId ) { + public static function validDatabase( $dbDomain ) { global $wgLocalDatabases; - return in_array( $wikiId, $wgLocalDatabases ); + return in_array( $dbDomain, $wgLocalDatabases ); } /** * Same as User::whoIs() * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param int $id User ID - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return string User name or false if the user doesn't exist */ - public static function whoIs( $wikiId, $id, $ignoreInvalidDB = false ) { - $user = self::newFromId( $wikiId, $id, $ignoreInvalidDB ); + public static function whoIs( $dbDomain, $id, $ignoreInvalidDB = false ) { + $user = self::newFromId( $dbDomain, $id, $ignoreInvalidDB ); if ( $user ) { return $user->name; } else { @@ -85,35 +85,35 @@ class UserRightsProxy { /** * Factory function; get a remote user entry by ID number. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param int $id User ID - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return UserRightsProxy|null If doesn't exist */ - public static function newFromId( $wikiId, $id, $ignoreInvalidDB = false ) { - return self::newFromLookup( $wikiId, 'user_id', intval( $id ), $ignoreInvalidDB ); + public static function newFromId( $dbDomain, $id, $ignoreInvalidDB = false ) { + return self::newFromLookup( $dbDomain, 'user_id', intval( $id ), $ignoreInvalidDB ); } /** * Factory function; get a remote user entry by name. * - * @param string $wikiId Database name + * @param string $dbDomain Database name * @param string $name User name - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return UserRightsProxy|null If doesn't exist */ - public static function newFromName( $wikiId, $name, $ignoreInvalidDB = false ) { - return self::newFromLookup( $wikiId, 'user_name', $name, $ignoreInvalidDB ); + public static function newFromName( $dbDomain, $name, $ignoreInvalidDB = false ) { + return self::newFromLookup( $dbDomain, 'user_name', $name, $ignoreInvalidDB ); } /** - * @param string $wikiId + * @param string $dbDomain * @param string $field * @param string $value * @param bool $ignoreInvalidDB * @return null|UserRightsProxy */ - private static function newFromLookup( $wikiId, $field, $value, $ignoreInvalidDB = false ) { + private static function newFromLookup( $dbDomain, $field, $value, $ignoreInvalidDB = false ) { global $wgSharedDB, $wgSharedTables; // If the user table is shared, perform the user query on it, // but don't pass it to the UserRightsProxy, @@ -121,10 +121,10 @@ class UserRightsProxy { if ( $wgSharedDB && in_array( 'user', $wgSharedTables ) ) { $userdb = self::getDB( $wgSharedDB, $ignoreInvalidDB ); } else { - $userdb = self::getDB( $wikiId, $ignoreInvalidDB ); + $userdb = self::getDB( $dbDomain, $ignoreInvalidDB ); } - $db = self::getDB( $wikiId, $ignoreInvalidDB ); + $db = self::getDB( $dbDomain, $ignoreInvalidDB ); if ( $db && $userdb ) { $row = $userdb->selectRow( 'user', @@ -134,7 +134,7 @@ class UserRightsProxy { if ( $row !== false ) { return new UserRightsProxy( - $db, $wikiId, $row->user_name, intval( $row->user_id ) ); + $db, $dbDomain, $row->user_name, intval( $row->user_id ) ); } } return null; @@ -144,17 +144,17 @@ class UserRightsProxy { * Open a database connection to work on for the requested user. * This may be a new connection to another database for remote users. * - * @param string $wikiId - * @param bool $ignoreInvalidDB If true, don't check if $wikiId is in $wgLocalDatabases + * @param string $dbDomain + * @param bool $ignoreInvalidDB If true, don't check if $dbDomain is in $wgLocalDatabases * @return IDatabase|null If invalid selection */ - public static function getDB( $wikiId, $ignoreInvalidDB = false ) { - if ( $ignoreInvalidDB || self::validDatabase( $wikiId ) ) { - if ( WikiMap::isCurrentWikiId( $wikiId ) ) { + public static function getDB( $dbDomain, $ignoreInvalidDB = false ) { + if ( $ignoreInvalidDB || self::validDatabase( $dbDomain ) ) { + if ( WikiMap::isCurrentWikiId( $dbDomain ) ) { // Hmm... this shouldn't happen though. :) return wfGetDB( DB_MASTER ); } else { - return wfGetDB( DB_MASTER, [], $wikiId ); + return wfGetDB( DB_MASTER, [], $dbDomain ); } } return null; @@ -180,7 +180,7 @@ class UserRightsProxy { * @return string */ public function getName() { - return $this->name . '@' . $this->wikiId; + return $this->name . '@' . $this->dbDomain; } /** diff --git a/includes/utils/ClassCollector.php b/includes/utils/ClassCollector.php index c987354d37..12b8a707bd 100644 --- a/includes/utils/ClassCollector.php +++ b/includes/utils/ClassCollector.php @@ -59,6 +59,31 @@ class ClassCollector { $this->alias = null; $this->tokens = []; + // HACK: The PHP tokenizer is slow (T225730). + // Speed it up by reducing the input to the three kinds of statement we care about: + // - namespace X; + // - [final] [abstract] class X … {} + // - class_alias( … ); + $lines = []; + $matches = null; + preg_match_all( + // phpcs:ignore Generic.Files.LineLength.TooLong + '#^\t*(?:namespace |(final )?(abstract )?(class|interface|trait) |class_alias\()[^;{]+[;{]\s*\}?#m', + $code, + $matches + ); + if ( isset( $matches[0][0] ) ) { + foreach ( $matches[0] as $match ) { + $match = trim( $match ); + if ( substr( $match, -1 ) === '{' ) { + // Keep it balanced + $match .= '}'; + } + $lines[] = $match; + } + } + $code = 'startToken === null ) { $this->tryBeginExpect( $token ); diff --git a/includes/watcheditem/WatchedItemQueryService.php b/includes/watcheditem/WatchedItemQueryService.php index 30e3cbee0e..f6ad623df3 100644 --- a/includes/watcheditem/WatchedItemQueryService.php +++ b/includes/watcheditem/WatchedItemQueryService.php @@ -4,7 +4,7 @@ use MediaWiki\Linker\LinkTarget; use MediaWiki\User\UserIdentity; use Wikimedia\Assert\Assert; use Wikimedia\Rdbms\IDatabase; -use Wikimedia\Rdbms\LoadBalancer; +use Wikimedia\Rdbms\ILoadBalancer; /** * Class performing complex database queries related to WatchedItems. @@ -53,7 +53,7 @@ class WatchedItemQueryService { const SORT_DESC = 'DESC'; /** - * @var LoadBalancer + * @var ILoadBalancer */ private $loadBalancer; @@ -70,7 +70,7 @@ class WatchedItemQueryService { private $watchedItemStore; public function __construct( - LoadBalancer $loadBalancer, + ILoadBalancer $loadBalancer, CommentStore $commentStore, ActorMigration $actorMigration, WatchedItemStoreInterface $watchedItemStore @@ -132,7 +132,7 @@ class WatchedItemQueryService { * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid') * if false (default) * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ] - * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ), + * @return array[] Array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ), * where $recentChangeInfo contains the following keys: * - 'rc_id', * - 'rc_namespace', diff --git a/includes/watcheditem/WatchedItemStore.php b/includes/watcheditem/WatchedItemStore.php index bd4360eb85..1a39945067 100644 --- a/includes/watcheditem/WatchedItemStore.php +++ b/includes/watcheditem/WatchedItemStore.php @@ -553,7 +553,7 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac * @since 1.27 * @param UserIdentity $user * @param LinkTarget $target - * @return bool + * @return WatchedItem|false */ public function getWatchedItem( UserIdentity $user, LinkTarget $target ) { if ( !$user->isRegistered() ) { @@ -573,7 +573,7 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac * @since 1.27 * @param UserIdentity $user * @param LinkTarget $target - * @return WatchedItem|bool + * @return WatchedItem|false */ public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) { // Only registered user can have a watchlist @@ -641,7 +641,7 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac // @todo: Should we add these to the process cache? $watchedItems[] = new WatchedItem( $user, - new TitleValue( (int)$row->wl_namespace, $row->wl_title ), + $target, $this->getLatestNotificationTimestamp( $row->wl_notificationtimestamp, $user, $target ) ); @@ -769,7 +769,7 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac foreach ( $rowBatches as $toInsert ) { // Use INSERT IGNORE to avoid overwriting the notification timestamp // if there's already an entry for this page - $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' ); + $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] ); $affectedRows += $dbw->affectedRows(); if ( $ticket ) { $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); @@ -1103,6 +1103,14 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac return "{$target->getNamespace()}:{$target->getDBkey()}"; } + /** + * @param UserIdentity $user + * @param LinkTarget $title + * @param WatchedItem $item + * @param bool $force + * @param int|bool $oldid The ID of the last revision that the user viewed + * @return bool|string|null + */ private function getNotificationTimestamp( UserIdentity $user, LinkTarget $title, $item, $force, $oldid ) { @@ -1112,7 +1120,8 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac } $oldRev = $this->revisionLookup->getRevisionById( $oldid ); - if ( !$this->revisionLookup->getNextRevision( $oldRev, $title ) ) { + $nextRev = $this->revisionLookup->getNextRevision( $oldRev ); + if ( !$nextRev ) { // Oldid given and is the latest revision for this title; clear the timestamp. return null; } @@ -1129,6 +1138,8 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac // Oldid given and isn't the latest; update the timestamp. // This will result in no further notification emails being sent! $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid ); + // @FIXME: this should use getTimestamp() for consistency with updates on new edits + // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp // We need to go one second to the future because of various strict comparisons // throughout the codebase diff --git a/includes/watcheditem/WatchedItemStoreInterface.php b/includes/watcheditem/WatchedItemStoreInterface.php index 5ff29d0d5d..1cf3288529 100644 --- a/includes/watcheditem/WatchedItemStoreInterface.php +++ b/includes/watcheditem/WatchedItemStoreInterface.php @@ -239,7 +239,7 @@ interface WatchedItemStoreInterface { * @param UserIdentity $editor The editor that triggered the update. Their notification * timestamp will not be updated(they have already seen it) * @param LinkTarget $target The target to update timestamps for - * @param string $timestamp Set the update timestamp to this value + * @param string $timestamp Set the update (first unseen revision) timestamp to this value * * @return int[] Array of user IDs the timestamp has been updated for */ @@ -341,7 +341,7 @@ interface WatchedItemStoreInterface { * @param string|null $timestamp Value of wl_notificationtimestamp from the DB * @param UserIdentity $user * @param LinkTarget $target - * @return string|null TS_MW timestamp or null if all revision were seen + * @return string|null TS_MW timestamp of first unseen revision or null if there isn't one */ public function getLatestNotificationTimestamp( $timestamp, UserIdentity $user, LinkTarget $target ); diff --git a/includes/widget/SearchInputWidget.php b/includes/widget/SearchInputWidget.php index d4ffed2c8c..2f0c23deeb 100644 --- a/includes/widget/SearchInputWidget.php +++ b/includes/widget/SearchInputWidget.php @@ -14,6 +14,7 @@ class SearchInputWidget extends TitleInputWidget { protected $validateTitle = false; protected $highlightFirst = false; protected $dataLocation = 'header'; + protected $showDescriptions = false; /** * @param array $config Configuration options @@ -41,6 +42,10 @@ class SearchInputWidget extends TitleInputWidget { $this->dataLocation = $config['dataLocation']; } + if ( !empty( $config['showDescriptions'] ) ) { + $this->showDescriptions = true; + } + // Initialization $this->addClasses( [ 'mw-widget-searchInputWidget' ] ); } @@ -58,6 +63,9 @@ class SearchInputWidget extends TitleInputWidget { if ( $this->dataLocation ) { $config['dataLocation'] = $this->dataLocation; } + if ( $this->showDescriptions ) { + $config['showDescriptions'] = true; + } $config['$overlay'] = true; return parent::getConfig( $config ); } diff --git a/includes/widget/search/BasicSearchResultSetWidget.php b/includes/widget/search/BasicSearchResultSetWidget.php index 1a885b0136..0e102878bc 100644 --- a/includes/widget/search/BasicSearchResultSetWidget.php +++ b/includes/widget/search/BasicSearchResultSetWidget.php @@ -117,12 +117,9 @@ class BasicSearchResultSetWidget { * @return string HTML */ protected function renderResultSet( SearchResultSet $resultSet, $offset ) { - $terms = MediaWikiServices::getInstance()->getContentLanguage()-> - convertForSearchResult( $resultSet->termMatches() ); - $hits = []; foreach ( $resultSet as $result ) { - $hits[] = $this->resultWidget->render( $result, $terms, $offset++ ); + $hits[] = $this->resultWidget->render( $result, $offset++ ); } return "
      " . implode( '', $hits ) . "
    "; diff --git a/includes/widget/search/FullSearchResultWidget.php b/includes/widget/search/FullSearchResultWidget.php index 66fc030ca4..7212dc0498 100644 --- a/includes/widget/search/FullSearchResultWidget.php +++ b/includes/widget/search/FullSearchResultWidget.php @@ -6,6 +6,7 @@ use Category; use Hooks; use HtmlArmor; use MediaWiki\Linker\LinkRenderer; +use MediaWiki\MediaWikiServices; use SearchResult; use SpecialSearch; use Title; @@ -30,11 +31,10 @@ class FullSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { // If the page doesn't *exist*... our search index is out of date. // The least confusing at this point is to drop the result. // You may get less results, but... on well. :P @@ -42,12 +42,15 @@ class FullSearchResultWidget implements SearchResultWidget { return ''; } - $link = $this->generateMainLinkHtml( $result, $terms, $position ); + $link = $this->generateMainLinkHtml( $result, $position ); // If page content is not readable, just return ths title. // This is not quite safe, but better than showing excerpts from // non-readable pages. Note that hiding the entry entirely would // screw up paging (really?). - if ( !$result->getTitle()->userCan( 'read', $this->specialPage->getUser() ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + if ( !$permissionManager->userCan( + 'read', $this->specialPage->getUser(), $result->getTitle() + ) ) { return "
  • {$link}
  • "; } @@ -59,7 +62,7 @@ class FullSearchResultWidget implements SearchResultWidget { $this->specialPage->getUser() ); list( $file, $desc, $thumb ) = $this->generateFileHtml( $result ); - $snippet = $result->getTextSnippet( $terms ); + $snippet = $result->getTextSnippet(); if ( $snippet ) { $extract = "
    $snippet
    "; } else { @@ -76,6 +79,9 @@ class FullSearchResultWidget implements SearchResultWidget { $html = null; $score = ''; $related = ''; + // TODO: remove this instanceof and always pass [], let implementors do the cast if + // they want to be SearchDatabase specific + $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : []; if ( !Hooks::run( 'ShowSearchHit', [ $this->specialPage, $result, $terms, &$link, &$redirect, &$section, &$extract, @@ -117,11 +123,10 @@ class FullSearchResultWidget implements SearchResultWidget { * title with highlighted words). * * @param SearchResult $result - * @param string $terms * @param int $position * @return string HTML */ - protected function generateMainLinkHtml( SearchResult $result, $terms, $position ) { + protected function generateMainLinkHtml( SearchResult $result, $position ) { $snippet = $result->getTitleSnippet(); if ( $snippet === '' ) { $snippet = null; @@ -135,7 +140,9 @@ class FullSearchResultWidget implements SearchResultWidget { $attributes = [ 'data-serp-pos' => $position ]; Hooks::run( 'ShowSearchHitTitle', - [ &$title, &$snippet, $result, $terms, $this->specialPage, &$query, &$attributes ] ); + [ &$title, &$snippet, $result, + $result instanceof \SqlSearchResult ? $result->getTermMatches() : [], + $this->specialPage, &$query, &$attributes ] ); $link = $this->linkRenderer->makeLink( $title, @@ -248,7 +255,8 @@ class FullSearchResultWidget implements SearchResultWidget { $descHtml = null; $thumbHtml = null; - $img = $result->getFile() ?: wfFindFile( $title ); + $img = $result->getFile() ?: MediaWikiServices::getInstance()->getRepoGroup() + ->findFile( $title ); if ( $img ) { $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] ); if ( $thumb ) { diff --git a/includes/widget/search/InterwikiSearchResultWidget.php b/includes/widget/search/InterwikiSearchResultWidget.php index 095c30a629..3f758db5bb 100644 --- a/includes/widget/search/InterwikiSearchResultWidget.php +++ b/includes/widget/search/InterwikiSearchResultWidget.php @@ -24,14 +24,13 @@ class InterwikiSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet(); - $snippet = $result->getTextSnippet( $terms ); + $snippet = $result->getTextSnippet(); if ( $titleSnippet ) { $titleSnippet = new HtmlArmor( $titleSnippet ); diff --git a/includes/widget/search/SearchFormWidget.php b/includes/widget/search/SearchFormWidget.php index 7c28b5efe7..62ee9cb6f1 100644 --- a/includes/widget/search/SearchFormWidget.php +++ b/includes/widget/search/SearchFormWidget.php @@ -40,6 +40,7 @@ class SearchFormWidget { * @param int $totalResults The total estimated results found * @param int $offset Current offset in search results * @param bool $isPowerSearch Is the 'advanced' section open? + * @param array $options Widget options * @return string HTML */ public function render( @@ -48,7 +49,8 @@ class SearchFormWidget { $numResults, $totalResults, $offset, - $isPowerSearch + $isPowerSearch, + array $options = [] ) { $user = $this->specialSearch->getUser(); @@ -63,7 +65,7 @@ class SearchFormWidget { ] ) . '
    ' . - $this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset ) . + $this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset, $options ) . '
    ' . "
    " . "
    " . @@ -81,12 +83,20 @@ class SearchFormWidget { * @param int $numResults The number of results shown * @param int $totalResults The total estimated results found * @param int $offset Current offset in search results + * @param array $options Widget options * @return string HTML */ - protected function shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset ) { + protected function shortDialogHtml( + $profile, + $term, + $numResults, + $totalResults, + $offset, + array $options = [] + ) { $html = ''; - $searchWidget = new SearchInputWidget( [ + $searchWidget = new SearchInputWidget( $options + [ 'id' => 'searchText', 'name' => 'search', 'autofocus' => trim( $term ) === '', diff --git a/includes/widget/search/SearchResultWidget.php b/includes/widget/search/SearchResultWidget.php index 3fbdbef2a9..e001395541 100644 --- a/includes/widget/search/SearchResultWidget.php +++ b/includes/widget/search/SearchResultWidget.php @@ -10,9 +10,8 @@ use SearchResult; interface SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The zero indexed result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ); + public function render( SearchResult $result, $position ); } diff --git a/includes/widget/search/SimpleSearchResultWidget.php b/includes/widget/search/SimpleSearchResultWidget.php index 552cbaf8ba..fe8b4d5092 100644 --- a/includes/widget/search/SimpleSearchResultWidget.php +++ b/includes/widget/search/SimpleSearchResultWidget.php @@ -26,11 +26,10 @@ class SimpleSearchResultWidget implements SearchResultWidget { /** * @param SearchResult $result The result to render - * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet) * @param int $position The result position, including offset * @return string HTML */ - public function render( SearchResult $result, $terms, $position ) { + public function render( SearchResult $result, $position ) { $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet(); if ( $titleSnippet ) { diff --git a/languages/Language.php b/languages/Language.php index 3a12439e6e..bb256c9c99 100644 --- a/languages/Language.php +++ b/languages/Language.php @@ -814,7 +814,8 @@ class Language { * @return array */ public function getExtraUserToggles() { - return (array)self::$dataCache->getItem( $this->mCode, 'extraUserToggles' ); + wfDeprecated( __METHOD__, '1.34' ); + return []; } /** @@ -2966,8 +2967,8 @@ class Language { } /** - * @param array $termsArray - * @return array + * @param string[] $termsArray + * @return string[] */ function convertForSearchResult( $termsArray ) { # some languages, e.g. Chinese, need to do a conversion @@ -4536,7 +4537,7 @@ class Language { * * @since 1.22 * @param string $code Language code - * @return array Array( fallbacks, site fallbacks ) + * @return array [ fallbacks, site fallbacks ] */ public static function getFallbacksIncludingSiteLanguage( $code ) { global $wgLanguageCode; @@ -4658,6 +4659,7 @@ class Language { * * @param int|float $seconds * @param array $format An optional argument that formats the returned string in different ways: + * If $format['avoid'] === 'avoidhours': don't show hours, just show days * If $format['avoid'] === 'avoidseconds': don't show seconds if $seconds >= 1 hour, * If $format['avoid'] === 'avoidminutes': don't show seconds/minutes if $seconds > 48 hours, * If $format['noabbrevs'] is true: use 'seconds' and friends instead of 'seconds-abbrev' @@ -4716,12 +4718,19 @@ class Language { $s = $hoursMsg->params( $this->formatNum( $hours ) )->text(); $s .= ' '; $s .= $minutesMsg->params( $this->formatNum( $minutes ) )->text(); - if ( !in_array( $format['avoid'], [ 'avoidseconds', 'avoidminutes' ] ) ) { + if ( !in_array( $format['avoid'], [ 'avoidseconds', 'avoidminutes', 'avoidhours' ] ) ) { $s .= ' ' . $secondsMsg->params( $this->formatNum( $secondsPart ) )->text(); } } else { $days = floor( $seconds / 86400 ); - if ( $format['avoid'] === 'avoidminutes' ) { + if ( $format['avoid'] === 'avoidhours' ) { + $hours = round( ( $seconds - $days * 86400 ) / 3600 ); + if ( $hours == 24 ) { + $hours = 0; + $days++; + } + $s = $daysMsg->params( $this->formatNum( $days ) )->text(); + } elseif ( $format['avoid'] === 'avoidminutes' ) { $hours = round( ( $seconds - $days * 86400 ) / 3600 ); if ( $hours == 24 ) { $hours = 0; @@ -4848,12 +4857,13 @@ class Language { * @param array $query Optional URL query parameter string * @param bool $atend Optional param for specified if this is the last page * @return string - * @deprecated since 1.33, use SpecialPage::viewPrevNext() + * @deprecated since 1.34, use PrevNextNavigationRenderer::buildPrevNextNavigation() * instead. */ public function viewPrevNext( Title $title, $offset, $limit, array $query = [], $atend = false ) { + wfDeprecated( __METHOD__, '1.34' ); // @todo FIXME: Why on earth this needs one message for the text and another one for tooltip? # Make 'previous' link diff --git a/languages/LanguageCode.php b/languages/LanguageCode.php deleted file mode 100644 index 7d954d3803..0000000000 --- a/languages/LanguageCode.php +++ /dev/null @@ -1,204 +0,0 @@ - 'gsw', // T25215 - 'bat-smg' => 'sgs', // T27522 - 'be-x-old' => 'be-tarask', // T11823 - 'fiu-vro' => 'vro', // T31186 - 'roa-rup' => 'rup', // T17988 - 'zh-classical' => 'lzh', // T30443 - 'zh-min-nan' => 'nan', // T30442 - 'zh-yue' => 'yue', // T30441 - ]; - - /** - * Mapping of non-standard language codes used in MediaWiki to - * standardized BCP 47 codes. These are not deprecated (yet?): - * IANA may eventually recognize the subtag, in which case the `-x-` - * infix could be removed, or else we could rename the code in - * MediaWiki, in which case they'd move up to the above mapping - * of deprecated codes. - * - * As a rule, we preserve all distinctions made by MediaWiki - * internally. For example, `de-formal` becomes `de-x-formal` - * instead of just `de` because MediaWiki distinguishes `de-formal` - * from `de` (for example, for interface translations). Similarly, - * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it - * "typically does not add information", but in our case MediaWiki - * LanguageConverter distinguishes `kk` (render content in a mix of - * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly - * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST, - * `kk-Cyrl` is a valid code, although some validators may emit - * a warning note. - * - * @var array Mapping from nonstandard MediaWiki-internal codes to - * BCP 47 codes - * - * @since 1.32 - * @see https://meta.wikimedia.org/wiki/Special_language_codes - * @see https://phabricator.wikimedia.org/T125073 - */ - private static $nonstandardLanguageCodeMapping = [ - // All codes returned by Language::fetchLanguageNames() validated - // against IANA registry at - // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - // with help of validator at - // http://schneegans.de/lv/ - 'cbk-zam' => 'cbk', // T124657 - 'de-formal' => 'de-x-formal', - 'eml' => 'egl', // T36217 - 'en-rtl' => 'en-x-rtl', - 'es-formal' => 'es-x-formal', - 'hu-formal' => 'hu-x-formal', - 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073 - 'mo' => 'ro-Cyrl-MD', // T125073 - 'nrm' => 'nrf', // [[en:Norman_language]] T25216 - 'nl-informal' => 'nl-x-informal', - 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]] - 'simple' => 'en-simple', - 'sr-ec' => 'sr-Cyrl', // T117845 - 'sr-el' => 'sr-Latn', // T117845 - - // Although these next codes aren't *wrong* per se, including - // both the script and the country code helps compatibility with - // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`, - // without a country code, and those should be left alone. - // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.) - 'zh-cn' => 'zh-Hans-CN', - 'zh-sg' => 'zh-Hans-SG', - 'zh-my' => 'zh-Hans-MY', - 'zh-tw' => 'zh-Hant-TW', - 'zh-hk' => 'zh-Hant-HK', - 'zh-mo' => 'zh-Hant-MO', - ]; - - /** - * Returns a mapping of deprecated language codes that were used in previous - * versions of MediaWiki to up-to-date, current language codes. - * - * This array is merged into $wgDummyLanguageCodes in Setup.php, along with - * the fake language codes 'qqq' and 'qqx', which are used internally by - * MediaWiki's localisation system. - * - * @return string[] - * - * @since 1.29 - */ - public static function getDeprecatedCodeMapping() { - return self::$deprecatedLanguageCodeMapping; - } - - /** - * Returns a mapping of non-standard language codes used by - * (current and previous version of) MediaWiki, mapped to standard - * BCP 47 names. - * - * This array is exported to JavaScript to ensure - * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47(). - * - * @return string[] - * - * @since 1.32 - */ - public static function getNonstandardLanguageCodeMapping() { - $result = []; - foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) { - $result[$code] = self::bcp47( $code ); - } - foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) { - $result[$code] = self::bcp47( $code ); - } - return $result; - } - - /** - * Replace deprecated language codes that were used in previous - * versions of MediaWiki to up-to-date, current language codes. - * Other values will returned unchanged. - * - * @param string $code Old language code - * @return string New language code - * - * @since 1.30 - */ - public static function replaceDeprecatedCodes( $code ) { - return self::$deprecatedLanguageCodeMapping[$code] ?? $code; - } - - /** - * Get the normalised IETF language tag - * See unit test for examples. - * See mediawiki.language.bcp47 for the JavaScript implementation. - * - * @param string $code The language code. - * @return string A language code complying with BCP 47 standards. - * - * @since 1.31 - */ - public static function bcp47( $code ) { - $code = self::replaceDeprecatedCodes( strtolower( $code ) ); - if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) { - $code = self::$nonstandardLanguageCodeMapping[$code]; - } - $codeSegment = explode( '-', $code ); - $codeBCP = []; - foreach ( $codeSegment as $segNo => $seg ) { - // when previous segment is x, it is a private segment and should be lc - if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) { - $codeBCP[$segNo] = strtolower( $seg ); - // ISO 3166 country code - } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) { - $codeBCP[$segNo] = strtoupper( $seg ); - // ISO 15924 script code - } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) { - $codeBCP[$segNo] = ucfirst( strtolower( $seg ) ); - // Use lowercase for other cases - } else { - $codeBCP[$segNo] = strtolower( $seg ); - } - } - $langCode = implode( '-', $codeBCP ); - return $langCode; - } -} diff --git a/languages/LanguageConverter.php b/languages/LanguageConverter.php index c5ff9d65a7..9fc7d73f0e 100644 --- a/languages/LanguageConverter.php +++ b/languages/LanguageConverter.php @@ -391,27 +391,30 @@ class LanguageConverter { IMPORTANT: Beware of failure from pcre.backtrack_limit (T124404). Minimize use of backtracking where possible. */ - $marker = '|' . Parser::MARKER_PREFIX . '[^\x7f]++\x7f'; - - // this one is needed when the text is inside an HTML markup - $htmlfix = '|<[^>\004]++(?=\004$)|^[^<>]*+>'; - - // Optimize for the common case where these tags have - // few or no children. Thus try and possesively get as much as - // possible, and only engage in backtracking when we hit a '<'. - - // disable convert to variants between tags - $codefix = '[^<]*+(?:(?:(?!<\/code>).)[^<]*+)*+<\/code>|'; - // disable conversion of " ], // Multiple only=styles load [ [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ], - '' + '' ], // Private embed (only=scripts) [ @@ -2744,14 +2611,14 @@ class OutputPageTest extends MediaWikiTestCase { // noscript group [ [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ], - '' + '' ], // Load two modules in separate groups [ [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ], "" ], ]; @@ -2774,7 +2641,8 @@ class OutputPageTest extends MediaWikiTestCase { // Set up stubs $ctx = new RequestContext(); - $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) ); + $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); + $ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) ); $ctx->setLanguage( 'en' ); $op = $this->getMockBuilder( OutputPage::class ) ->setConstructorArgs( [ $ctx ] ) @@ -2806,22 +2674,22 @@ class OutputPageTest extends MediaWikiTestCase { return [ 'empty' => [ 'exemptStyleModules' => [], - '', + '', ], 'empty sets' => [ 'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ], - '', + '', ], 'default logged-out' => [ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ], '' . "\n" . - '', + '', ], 'default logged-in' => [ 'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ], '' . "\n" . - '' . "\n" . - '', + '' . "\n" . + '', ], 'custom modules' => [ 'exemptStyleModules' => [ @@ -2829,10 +2697,10 @@ class OutputPageTest extends MediaWikiTestCase { 'user' => [ 'user.styles', 'example.user' ], ], '' . "\n" . - '' . "\n" . - '' . "\n" . - '' . "\n" . - '', + '' . "\n" . + '' . "\n" . + '' . "\n" . + '', ], ]; // phpcs:enable diff --git a/tests/phpunit/includes/Permissions/PermissionManagerTest.php b/tests/phpunit/includes/Permissions/PermissionManagerTest.php index 4676fc545e..3da73c148e 100644 --- a/tests/phpunit/includes/Permissions/PermissionManagerTest.php +++ b/tests/phpunit/includes/Permissions/PermissionManagerTest.php @@ -3,16 +3,21 @@ namespace MediaWiki\Tests\Permissions; use Action; -use Block; +use FauxRequest; +use MediaWiki\Session\SessionId; +use MediaWiki\Session\TestUtils; use MediaWikiLangTestCase; use RequestContext; +use stdClass; use Title; use User; +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\SystemBlock; use MediaWiki\MediaWikiServices; use MediaWiki\Permissions\PermissionManager; +use Wikimedia\TestingAccessWrapper; /** * @group Database @@ -56,7 +61,32 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'wgNamespaceProtection' => [ NS_MEDIAWIKI => 'editinterface', ], + 'wgRevokePermissions' => [ + 'formertesters' => [ + 'runtest' => true + ] + ], + 'wgAvailableRights' => [ + 'test', + 'runtest', + 'writetest', + 'nukeworld', + 'modifytest', + 'editmyoptions' + ] ] ); + + $this->setGroupPermissions( 'unittesters', 'test', true ); + $this->setGroupPermissions( 'unittesters', 'runtest', true ); + $this->setGroupPermissions( 'unittesters', 'writetest', false ); + $this->setGroupPermissions( 'unittesters', 'nukeworld', false ); + + $this->setGroupPermissions( 'testwriters', 'test', true ); + $this->setGroupPermissions( 'testwriters', 'writetest', true ); + $this->setGroupPermissions( 'testwriters', 'modifytest', true ); + + $this->setGroupPermissions( '*', 'editmyoptions', true ); + // Without this testUserBlock will use a non-English context on non-English MediaWiki // installations (because of how Title::checkUserBlock is implemented) and fail. RequestContext::resetMain(); @@ -89,19 +119,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->user = $this->userUser; } - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - - $this->overrideMwServices(); + $this->resetServices(); } - protected function setUserPerm( $perm ) { - // Setting member variables is evil!!! - - if ( is_array( $perm ) ) { - $this->user->mRights = $perm; - } else { - $this->user->mRights = [ $perm ]; - } + public function tearDown() { + parent::tearDown(); + $this->restoreMwServices(); } protected function setTitle( $ns, $title = "Main_Page" ) { @@ -116,6 +139,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { } else { $this->user = $this->altUser; } + $this->resetServices(); } /** @@ -133,163 +157,165 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setUser( 'anon' ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ "nocreatetext" ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createpage" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "createtalk" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "movefile" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "movefile" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); + // $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + // $this->setUserPerm( "" ); $this->runGroupPermissions( + '', 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ] ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); + //$this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + // $this->setUserPerm( "" ); $this->runGroupPermissions( + '', 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ] @@ -301,58 +327,58 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [] ); + // $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [] ); - $this->setUserPerm( "" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ], + // $this->setUserPerm( "" ); + $this->runGroupPermissions( '', 'move', [ [ 'movenotallowed' ] ], [ [ 'movenologintext' ] ] ); $this->setUser( $this->userName ); - $this->setUserPerm( "" ); - $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ] ); + // $this->setUserPerm( "" ); + $this->runGroupPermissions( '', 'move', [ [ 'movenotallowed' ] ] ); - $this->setUserPerm( "move" ); - $this->runGroupPermissions( 'move', [] ); + //$this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', 'move', [] ); $this->setUser( 'anon' ); - $this->setUserPerm( 'move' ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'move' ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( '' ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, '' ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); } $this->setTitle( NS_USER ); $this->setUser( $this->userName ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [ [ 'cant-move-to-user-page' ] ], $res ); $this->setUser( 'anon' ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); $this->setTitle( NS_USER, "User/subpage" ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); - $res = $this->permissionManager + $this->overrideUserPermissions( $this->user, "move" ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ); $this->assertEquals( [], $res ); @@ -378,54 +404,58 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; foreach ( [ "edit", "protect", "" ] as $action ) { - $this->setUserPerm( null ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][0], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); global $wgGroupPermissions; $old = $wgGroupPermissions; $wgGroupPermissions = []; + $this->resetServices(); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][1], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); $wgGroupPermissions = $old; + $this->resetServices(); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'full' ) ); $this->assertEquals( $check[$action][2], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title, 'secure' ) ); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][3], - $this->permissionManager->userCan( $action, $this->user, $this->title, true ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( $action, $this->user, $this->title, true ) ); $this->assertEquals( $check[$action][3], - $this->permissionManager->userCan( $action, $this->user, $this->title, + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( $action, $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); # count( User::getGroupsWithPermissions( $action ) ) < 1 } } - protected function runGroupPermissions( $action, $result, $result2 = null ) { + protected function runGroupPermissions( $perm, $action, $result, $result2 = null ) { global $wgGroupPermissions; if ( $result2 === null ) { @@ -434,25 +464,33 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = false; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = false; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = true; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = true; - $res = $this->permissionManager + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $perm ); + $res = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( $action, $this->user, $this->title ); $this->assertEquals( $result2, $res ); } @@ -469,57 +507,59 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_SPECIAL ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'ns-specialprotected' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $wgNamespaceProtection[NS_USER] = [ 'bogus' ]; $this->setTitle( NS_USER ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'namespaceprotected', 'User', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $wgNamespaceProtection = null; - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title ) ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user, '' ); $this->assertEquals( [ [ 'badaccess-group0' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title ) ); } /** @@ -716,48 +756,48 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $resultUserJs, $resultPatrol ) { - $this->setUserPerm( '' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultNone, $result ); - $this->setUserPerm( 'editmyusercss' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyusercss' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyCss, $result ); - $this->setUserPerm( 'editmyuserjson' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyuserjson' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyJson, $result ); - $this->setUserPerm( 'editmyuserjs' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editmyuserjs' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultMyJs, $result ); - $this->setUserPerm( 'editusercss' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'editusercss' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserCss, $result ); - $this->setUserPerm( 'edituserjson' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'edituserjson' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserJson, $result ); - $this->setUserPerm( 'edituserjs' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, 'edituserjs' ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( $resultUserJs, $result ); - $this->setUserPerm( '' ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ); $this->assertEquals( reset( $resultPatrol[0] ), reset( $result[0] ) ); - $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] ); - $result = $this->permissionManager + $this->overrideUserPermissions( $this->user, [ 'edituserjs', 'edituserjson', 'editusercss' ] ); + $result = MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'bogus', $this->user, $this->title ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $result ); } @@ -777,16 +817,16 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->title->mRestrictionsLoaded = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->title->mRestrictions = [ "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->title->mRestrictions = [ "edit" => [ 'bogus', "sysop", "protect", "" ], "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; @@ -795,81 +835,81 @@ class PermissionManagerTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); - $this->setUserPerm( "" ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ], [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); $this->title->mCascadeRestriction = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager() + ->userCan( 'bogus', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'editprotected', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'bogus', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'edit', $this->user, $this->title, - PermissionManager::RIGOR_QUICK ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ 'protectedpagetext', 'bogus', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ], [ 'protectedpagetext', 'protect', 'edit' ] ], - $this->permissionManager->getPermissionErrors( 'edit', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); } /** @@ -877,7 +917,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { */ public function testCascadingSourcesRestrictions() { $this->setTitle( NS_MAIN, "test page" ); - $this->setUserPerm( [ "edit", "bogus" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "bogus" ] ); $this->title->mCascadeSources = [ Title::makeTitle( NS_MAIN, "Bogus" ), @@ -888,17 +928,21 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; $this->assertEquals( false, - $this->permissionManager->userCan( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( [ [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ], [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ], [ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ] ], - $this->permissionManager->getPermissionErrors( 'bogus', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'bogus', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'edit', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'edit', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'edit', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'edit', $this->user, $this->title ) ); } /** @@ -907,7 +951,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { * @covers \MediaWiki\Permissions\PermissionManager::checkActionPermissions */ public function testActionPermissions() { - $this->setUserPerm( [ "createpage" ] ); + $this->overrideUserPermissions( $this->user, [ "createpage" ] ); $this->setTitle( NS_MAIN, "test page" ); $this->title->mTitleProtection['permission'] = ''; $this->title->mTitleProtection['user'] = $this->user->getId(); @@ -916,75 +960,85 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->title->mCascadeRestriction = false; $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); $this->title->mTitleProtection['permission'] = 'editprotected'; - $this->setUserPerm( [ 'createpage', 'protect' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'protect' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); - $this->setUserPerm( [ 'createpage', 'editprotected' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'editprotected' ] ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); - $this->setUserPerm( [ 'createpage' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'create', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'create', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'create', $this->user, $this->title ) ); $this->setTitle( NS_MEDIA, "test page" ); - $this->setUserPerm( [ "move" ] ); + $this->overrideUserPermissions( $this->user, [ "move" ] ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->assertEquals( [ [ 'immobile-source-namespace', 'Media' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->setTitle( NS_HELP, "test page" ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->title->mInterwiki = "no"; $this->assertEquals( [ [ 'immobile-source-page' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move', $this->user, $this->title ) ); $this->setTitle( NS_MEDIA, "test page" ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); $this->assertEquals( [ [ 'immobile-target-namespace', 'Media' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->setTitle( NS_HELP, "test page" ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( true, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); $this->title->mInterwiki = "no"; $this->assertEquals( [ [ 'immobile-target-page' ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( false, - $this->permissionManager->userCan( 'move-target', $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->userCan( + 'move-target', $this->user, $this->title ) ); } /** @@ -997,10 +1051,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'wgBlockDisablesLogin' => false, ] ); - $this->overrideMwServices(); - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - - $this->setUserPerm( [ + $this->overrideUserPermissions( $this->user, [ 'createpage', 'edit', 'move', @@ -1013,30 +1064,38 @@ class PermissionManagerTest extends MediaWikiLangTestCase { # $wgEmailConfirmToEdit only applies to 'edit' action $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); $this->assertContains( [ 'confirmedittext' ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->setMwGlobals( 'wgEmailConfirmToEdit', false ); - $this->overrideMwServices(); - $this->permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $this->resetServices(); + $this->overrideUserPermissions( $this->user, [ + 'createpage', + 'edit', + 'move', + 'rollback', + 'patrol', + 'upload', + 'purge' + ] ); $this->assertNotContains( [ 'confirmedittext' ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); # $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount' $this->assertEquals( [], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); global $wgLang; $prev = time(); $now = time() + 120; $this->user->mBlockedby = $this->user->getId(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1046,23 +1105,23 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $this->user->mBlock->mTimestamp = 0; $this->assertEquals( [ [ 'autoblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ] ], - $this->permissionManager->getPermissionErrors( 'move-target', - $this->user, $this->title ) ); + MediaWikiServices::getInstance()->getPermissionManager()->getPermissionErrors( + 'move-target', $this->user, $this->title ) ); - $this->assertEquals( false, $this->permissionManager + $this->assertEquals( false, MediaWikiServices::getInstance()->getPermissionManager() ->userCan( 'move-target', $this->user, $this->title ) ); // quickUserCan should ignore user blocks - $this->assertEquals( true, $this->permissionManager + $this->assertEquals( true, MediaWikiServices::getInstance()->getPermissionManager() ->userCan( 'move-target', $this->user, $this->title, PermissionManager::RIGOR_QUICK ) ); global $wgLocalTZoffset; $wgLocalTZoffset = -60; $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1071,10 +1130,10 @@ class PermissionManagerTest extends MediaWikiLangTestCase { 'expiry' => 10, ] ); $this->assertEquals( [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) # $user->blockedFor() == '' @@ -1091,32 +1150,32 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'systemblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', 'test', 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", 'test', 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); // partial block message test $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1126,22 +1185,22 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); $this->user->mBlock->setRestrictions( [ @@ -1149,27 +1208,27 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext-partial', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'move-target', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'rollback', $this->user, $this->title ) ); $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'patrol', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'upload', $this->user, $this->title ) ); $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'purge', $this->user, $this->title ) ); // Test no block. @@ -1177,7 +1236,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $this->user->mBlock = null; $this->assertEquals( [], - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'edit', $this->user, $this->title ) ); } @@ -1213,7 +1272,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $now = time(); $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1223,12 +1282,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, - $this->permissionManager + MediaWikiServices::getInstance()->getPermissionManager() ->getPermissionErrors( 'tester', $this->user, $this->title ) ); } @@ -1243,12 +1302,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { //$this->assertSame( '', $user->blockedBy(), 'sanity check' ); //$this->assertSame( '', $user->blockedFor(), 'sanity check' ); //$this->assertFalse( (bool)$user->isHidden(), 'sanity check' ); - $this->assertFalse( $this->permissionManager + $this->assertFalse( MediaWikiServices::getInstance()->getPermissionManager() ->isBlockedFrom( $user, $ut ), 'sanity check' ); // Block the user $blocker = $this->getTestSysop()->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'hideName' => true, 'allowUsertalk' => false, 'reason' => 'Because', @@ -1260,11 +1319,12 @@ class PermissionManagerTest extends MediaWikiLangTestCase { // Clear cache and confirm it loaded the block properly $user->clearInstanceCache(); - $this->assertInstanceOf( Block::class, $user->getBlock( false ) ); + $this->assertInstanceOf( DatabaseBlock::class, $user->getBlock( false ) ); //$this->assertSame( $blocker->getName(), $user->blockedBy() ); //$this->assertSame( 'Because', $user->blockedFor() ); //$this->assertTrue( (bool)$user->isHidden() ); - $this->assertTrue( $this->permissionManager->isBlockedFrom( $user, $ut ) ); + $this->assertTrue( MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $ut ) ); // Unblock $block->delete(); @@ -1275,7 +1335,8 @@ class PermissionManagerTest extends MediaWikiLangTestCase { //$this->assertSame( '', $user->blockedBy() ); //$this->assertSame( '', $user->blockedFor() ); //$this->assertFalse( (bool)$user->isHidden() ); - $this->assertFalse( $this->permissionManager->isBlockedFrom( $user, $ut ) ); + $this->assertFalse( MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $ut ) ); } /** @@ -1285,7 +1346,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { * @param bool $expect Expected result from User::isBlockedFrom() * @param array $options Additional test options: * - 'blockAllowsUTEdit': (bool, default true) Value for $wgBlockAllowsUTEdit - * - 'allowUsertalk': (bool, default false) Passed to Block::__construct() + * - 'allowUsertalk': (bool, default false) Passed to DatabaseBlock::__construct() * - 'pageRestrictions': (array|null) If non-empty, page restriction titles for the block. */ public function testIsBlockedFrom( $title, $expect, array $options = [] ) { @@ -1312,7 +1373,7 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $restrictions[] = new NamespaceRestriction( 0, $ns ); } - $block = new Block( [ + $block = new DatabaseBlock( [ 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), 'allowUsertalk' => $options['allowUsertalk'] ?? false, 'sitewide' => !$restrictions, @@ -1325,7 +1386,8 @@ class PermissionManagerTest extends MediaWikiLangTestCase { $block->insert(); try { - $this->assertSame( $expect, $this->permissionManager->isBlockedFrom( $user, $title ) ); + $this->assertSame( $expect, MediaWikiServices::getInstance()->getPermissionManager() + ->isBlockedFrom( $user, $title ) ); } finally { $block->delete(); } @@ -1408,4 +1470,187 @@ class PermissionManagerTest extends MediaWikiLangTestCase { ]; } + /** + * @covers \MediaWiki\Permissions\PermissionManager::getUserPermissions + */ + public function testGetUserPermissions() { + $user = $this->getTestUser( [ 'unittesters' ] )->getUser(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getUserPermissions + */ + public function testGetUserPermissionsHooks() { + $user = $this->getTestUser( [ 'unittesters', 'testwriters' ] )->getUser(); + $userWrapper = TestingAccessWrapper::newFromObject( $user ); + + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights, 'sanity check' ); + $this->assertContains( 'runtest', $rights, 'sanity check' ); + $this->assertContains( 'writetest', $rights, 'sanity check' ); + $this->assertNotContains( 'nukeworld', $rights, 'sanity check' ); + + // Add a hook manipluating the rights + $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) { + $rights[] = 'nukeworld'; + $rights = array_diff( $rights, [ 'writetest' ] ); + } ] ] ); + + $this->resetServices(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertContains( 'nukeworld', $rights ); + + // Add a Session that limits rights + $mock = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] ) + ->getMock(); + $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] ); + $mock->method( 'getSessionId' )->willReturn( + new SessionId( str_repeat( 'X', 32 ) ) + ); + $session = TestUtils::getDummySession( $mock ); + $mockRequest = $this->getMockBuilder( FauxRequest::class ) + ->setMethods( [ 'getSession' ] ) + ->getMock(); + $mockRequest->method( 'getSession' )->willReturn( $session ); + $userWrapper->mRequest = $mockRequest; + + $this->resetServices(); + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $user ); + $this->assertContains( 'test', $rights ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getGroupPermissions + */ + public function testGroupPermissions() { + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters' ] ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters', 'testwriters' ] ); + $this->assertContains( 'runtest', $rights ); + $this->assertContains( 'writetest', $rights ); + $this->assertContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::getGroupPermissions + */ + public function testRevokePermissions() { + $rights = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupPermissions( [ 'unittesters', 'formertesters' ] ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @dataProvider provideGetGroupsWithPermission + * @covers \MediaWiki\Permissions\PermissionManager::getGroupsWithPermission + */ + public function testGetGroupsWithPermission( $expected, $right ) { + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->getGroupsWithPermission( $right ); + sort( $result ); + sort( $expected ); + + $this->assertEquals( $expected, $result, "Groups with permission $right" ); + } + + public static function provideGetGroupsWithPermission() { + return [ + [ + [ 'unittesters', 'testwriters' ], + 'test' + ], + [ + [ 'unittesters' ], + 'runtest' + ], + [ + [ 'testwriters' ], + 'writetest' + ], + [ + [ 'testwriters' ], + 'modifytest' + ], + ]; + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::userHasRight + */ + public function testUserHasRight() { + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'unittesters' )->getUser(), + 'test' + ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'formertesters' )->getUser(), + 'runtest' + ); + $this->assertFalse( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->userHasRight( + $this->getTestUser( 'formertesters' )->getUser(), + '' + ); + $this->assertTrue( $result ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::groupHasPermission + */ + public function testGroupHasPermission() { + $result = MediaWikiServices::getInstance()->getPermissionManager()->groupHasPermission( + 'unittesters', + 'test' + ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager()->groupHasPermission( + 'formertesters', + 'runtest' + ); + $this->assertFalse( $result ); + } + + /** + * @covers \MediaWiki\Permissions\PermissionManager::isEveryoneAllowed + */ + public function testIsEveryoneAllowed() { + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->isEveryoneAllowed( 'editmyoptions' ); + $this->assertTrue( $result ); + + $result = MediaWikiServices::getInstance()->getPermissionManager() + ->isEveryoneAllowed( 'test' ); + $this->assertFalse( $result ); + } + } diff --git a/tests/phpunit/includes/Rest/ResponseFactoryTest.php b/tests/phpunit/includes/Rest/ResponseFactoryTest.php new file mode 100644 index 0000000000..ae71272f6b --- /dev/null +++ b/tests/phpunit/includes/Rest/ResponseFactoryTest.php @@ -0,0 +1,146 @@ +assertSame( $expected, $rf->encodeJson( $input ) ); + } + + public function testCreateJson() { + $rf = new ResponseFactory; + $response = $rf->createJson( [] ); + $response->getBody()->rewind(); + $this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) ); + $this->assertSame( '[]', $response->getBody()->getContents() ); + // Make sure getSize() is functional, since testCreateNoContent() depends on it + $this->assertSame( 2, $response->getBody()->getSize() ); + } + + public function testCreateNoContent() { + $rf = new ResponseFactory; + $response = $rf->createNoContent(); + $this->assertSame( [], $response->getHeader( 'Content-Type' ) ); + $this->assertSame( 0, $response->getBody()->getSize() ); + $this->assertSame( 204, $response->getStatusCode() ); + } + + public function testCreatePermanentRedirect() { + $rf = new ResponseFactory; + $response = $rf->createPermanentRedirect( 'http://www.example.com/' ); + $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) ); + $this->assertSame( 301, $response->getStatusCode() ); + } + + public function testCreateLegacyTemporaryRedirect() { + $rf = new ResponseFactory; + $response = $rf->createLegacyTemporaryRedirect( 'http://www.example.com/' ); + $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) ); + $this->assertSame( 302, $response->getStatusCode() ); + } + + public function testCreateTemporaryRedirect() { + $rf = new ResponseFactory; + $response = $rf->createTemporaryRedirect( 'http://www.example.com/' ); + $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) ); + $this->assertSame( 307, $response->getStatusCode() ); + } + + public function testCreateSeeOther() { + $rf = new ResponseFactory; + $response = $rf->createSeeOther( 'http://www.example.com/' ); + $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) ); + $this->assertSame( 303, $response->getStatusCode() ); + } + + public function testCreateNotModified() { + $rf = new ResponseFactory; + $response = $rf->createNotModified(); + $this->assertSame( 0, $response->getBody()->getSize() ); + $this->assertSame( 304, $response->getStatusCode() ); + } + + /** @expectedException \InvalidArgumentException */ + public function testCreateHttpErrorInvalid() { + $rf = new ResponseFactory; + $rf->createHttpError( 200 ); + } + + public function testCreateHttpError() { + $rf = new ResponseFactory; + $response = $rf->createHttpError( 415, [ 'message' => '...' ] ); + $this->assertSame( 415, $response->getStatusCode() ); + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 415, $data['httpCode'] ); + $this->assertSame( '...', $data['message'] ); + } + + public function testCreateFromExceptionUnlogged() { + $rf = new ResponseFactory; + $response = $rf->createFromException( new HttpException( 'hello', 415 ) ); + $this->assertSame( 415, $response->getStatusCode() ); + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 415, $data['httpCode'] ); + $this->assertSame( 'hello', $data['message'] ); + } + + public function testCreateFromExceptionLogged() { + $rf = new ResponseFactory; + $response = $rf->createFromException( new \Exception( "hello", 415 ) ); + $this->assertSame( 500, $response->getStatusCode() ); + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 500, $data['httpCode'] ); + $this->assertSame( 'Error: exception of type Exception', $data['message'] ); + } + + public static function provideCreateFromReturnValue() { + return [ + [ 'hello', '{"value":"hello"}' ], + [ true, '{"value":true}' ], + [ [ 'x' => 'y' ], '{"x":"y"}' ], + [ [ 'x', 'y' ], '["x","y"]' ], + [ [ 'a', 'x' => 'y' ], '{"0":"a","x":"y"}' ], + [ (object)[ 'a', 'x' => 'y' ], '{"0":"a","x":"y"}' ], + [ [], '[]' ], + [ (object)[], '{}' ], + ]; + } + + /** @dataProvider provideCreateFromReturnValue */ + public function testCreateFromReturnValue( $input, $expected ) { + $rf = new ResponseFactory; + $response = $rf->createFromReturnValue( $input ); + $body = $response->getBody(); + $body->rewind(); + $this->assertSame( $expected, $body->getContents() ); + } + + /** @expectedException \InvalidArgumentException */ + public function testCreateFromReturnValueInvalid() { + $rf = new ResponseFactory; + $rf->createFromReturnValue( new ArrayIterator ); + } +} diff --git a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php deleted file mode 100644 index aedf292eca..0000000000 --- a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::__construct - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getRole() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getNameMessageKey() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getDefaultModel() - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getOutputLayoutHints() - */ - public function testConstruction() { - $handler = new FallbackSlotRoleHandler( 'foo' ); - $this->assertSame( 'foo', $handler->getRole() ); - $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); - - $title = $this->makeBlankTitleObject(); - $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title ) ); - - $hints = $handler->getOutputLayoutHints(); - $this->assertArrayHasKey( 'display', $hints ); - $this->assertArrayHasKey( 'region', $hints ); - $this->assertArrayHasKey( 'placement', $hints ); - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedModel() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - // For the fallback handler, no models are allowed - $title = $this->makeBlankTitleObject(); - $this->assertFalse( $handler->isAllowedModel( 'FooModel', $title ) ); - $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedOn() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - $title = $this->makeBlankTitleObject(); - $this->assertFalse( $handler->isAllowedOn( $title ) ); - } - - /** - * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::supportsArticleCount() - */ - public function testSupportsArticleCount() { - $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); - - $this->assertFalse( $handler->supportsArticleCount() ); - } - -} diff --git a/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php index 8bf8606788..43a698a869 100644 --- a/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php @@ -1,4 +1,5 @@ $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php b/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php index fdf2629ea9..2891d5ad73 100644 --- a/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php +++ b/tests/phpunit/includes/Revision/McrReadNewSchemaOverride.php @@ -1,4 +1,5 @@ assertRevisionRecordsEqual( $return, $loaded ); } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'slot_revision_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/McrSchemaDetection.php b/tests/phpunit/includes/Revision/McrSchemaDetection.php index 3831ef2294..3ae4c03221 100644 --- a/tests/phpunit/includes/Revision/McrSchemaDetection.php +++ b/tests/phpunit/includes/Revision/McrSchemaDetection.php @@ -1,4 +1,5 @@ assertRevisionExistsInDatabase( $return ); } + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + protected function getSlotRevisionConditions( $revId ) { + return [ 'rev_id' => $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php b/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php index 4ca7fdbd10..c750f1f080 100644 --- a/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php +++ b/tests/phpunit/includes/Revision/McrWriteBothSchemaOverride.php @@ -1,4 +1,5 @@ $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php index b3c34c8281..468ab60800 100644 --- a/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php +++ b/tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php @@ -1,4 +1,5 @@ $revId ]; + } + } diff --git a/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php b/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php index fb7ef77b47..1fe2d6f8e5 100644 --- a/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php +++ b/tests/phpunit/includes/Revision/PreMcrSchemaOverride.php @@ -1,4 +1,5 @@ [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), ] ), @@ -751,19 +752,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], @@ -777,19 +779,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content', 'model', 'role' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => $db->buildConcat( [ - $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], @@ -803,13 +806,13 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), ] ), @@ -824,19 +827,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase { [ 'content' ], [ 'tables' => [ - 'slots' => 'revision', + 'revision', ], 'fields' => array_merge( [ - 'slot_revision_id' => 'slots.rev_id', + 'slot_revision_id' => 'rev_id', 'slot_content_id' => 'NULL', - 'slot_origin' => 'slots.rev_id', + 'slot_origin' => 'rev_id', 'role_name' => $db->addQuotes( SlotRecord::MAIN ), - 'content_size' => 'slots.rev_len', - 'content_sha1' => 'slots.rev_sha1', + 'content_size' => 'rev_len', + 'content_sha1' => 'rev_sha1', 'content_address' => - $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ), - 'model_name' => 'slots.rev_content_model', + $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] ), + 'rev_text_id' => 'rev_text_id', + 'model_name' => 'rev_content_model', ] ), 'joins' => [], diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php index 071ea68347..d1418c28c8 100644 --- a/tests/phpunit/includes/Revision/RevisionRendererTest.php +++ b/tests/phpunit/includes/Revision/RevisionRendererTest.php @@ -19,7 +19,6 @@ use ParserOptions; use ParserOutput; use PHPUnit\Framework\MockObject\MockObject; use Title; -use User; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\ILoadBalancer; use WikitextContent; @@ -74,12 +73,8 @@ class RevisionRendererTest extends MediaWikiTestCase { } ); $mock->expects( $this->any() ) - ->method( 'userCan' ) - ->willReturnCallback( - function ( $perm, User $user ) use ( $mock ) { - return $user->isAllowed( $perm ); - } - ); + ->method( 'getRestrictions' ) + ->willReturn( [] ); return $mock; } @@ -368,6 +363,7 @@ class RevisionRendererTest extends MediaWikiTestCase { $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged! $rr = $renderer->getRenderedRevision( $rev, $options, $sysop ); + $this->assertNotNull( $rr, 'getRenderedRevision' ); $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' ); $this->assertSame( $rev, $rr->getRevision() ); diff --git a/tests/phpunit/includes/Revision/RevisionStoreCacheRecordTest.php b/tests/phpunit/includes/Revision/RevisionStoreCacheRecordTest.php index 8684cd392b..41e3e26c07 100644 --- a/tests/phpunit/includes/Revision/RevisionStoreCacheRecordTest.php +++ b/tests/phpunit/includes/Revision/RevisionStoreCacheRecordTest.php @@ -1,4 +1,5 @@ hasContentId() ) { - $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); + // XXX: the content ID value is ill-defined when SCHEMA_COMPAT_WRITE_BOTH and + // SCHEMA_COMPAT_READ_OLD is set, since revision insertion will report the + // content ID used with the new schema, while loading the revision from the + // old schema will report an emulated ID. + if ( $this->getMcrMigrationStage() & SCHEMA_COMPAT_READ_NEW ) { + $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' ); + } } } + /** + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots + * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo + */ + public function testNewRevisionFromRowAndSlot_getQueryInfo() { + $page = $this->getTestPage(); + $text = __METHOD__ . 'o-ö'; + /** @var Revision $rev */ + $rev = $page->doEditContent( + new WikitextContent( $text ), + __METHOD__ . 'a' + )->value['revision']; + + $store = MediaWikiServices::getInstance()->getRevisionStore(); + $info = $store->getQueryInfo(); + $row = $this->db->selectRow( + $info['tables'], + $info['fields'], + [ 'rev_id' => $rev->getId() ], + __METHOD__, + [], + $info['joins'] + ); + + $info = $store->getSlotsQueryInfo( [ 'content' ] ); + $slotRows = $this->db->select( + $info['tables'], + $info['fields'], + $this->getSlotRevisionConditions( $rev->getId() ), + __METHOD__, + [], + $info['joins'] + ); + + $record = $store->newRevisionFromRowAndSlots( + $row, + iterator_to_array( $slotRows ), + [], + $page->getTitle() + ); + $this->assertRevisionRecordMatchesRevision( $rev, $record ); + $this->assertSame( $text, $rev->getContent()->serialize() ); + } + + /** + * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given + * revision. + * + * @return array + */ + abstract protected function getSlotRevisionConditions( $revId ); + /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo */ public function testNewRevisionFromRow_getQueryInfo() { @@ -935,6 +994,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_anonEdit() { $page = $this->getTestPage(); @@ -957,6 +1017,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_anonEdit_legacyEncoding() { $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' ); @@ -981,6 +1042,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_userEdit() { $page = $this->getTestPage(); @@ -1105,6 +1167,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { /** * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow + * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots */ public function testNewRevisionFromRow_no_user() { $store = MediaWikiServices::getInstance()->getRevisionStore(); @@ -1690,8 +1753,10 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase { */ public function testNewMutableRevisionFromArray_legacyEncoding( array $array ) { $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); $factory = $this->getMockBuilder( BlobStoreFactory::class ) diff --git a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php deleted file mode 100644 index 138d6bcba1..0000000000 --- a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php +++ /dev/null @@ -1,197 +0,0 @@ -getMockLoadBalancerFactory(), - $this->getMockBlobStoreFactory(), - $this->getNameTableStoreFactory(), - $this->getMockSlotRoleRegistry(), - $this->getHashWANObjectCache(), - $this->getMockCommentStore(), - ActorMigration::newMigration(), - MIGRATION_OLD, - $this->getMockLoggerSpi(), - true - ); - $this->assertTrue( true ); - } - - public function provideWikiIds() { - yield [ true ]; - yield [ false ]; - yield [ 'somewiki' ]; - yield [ 'somewiki', MIGRATION_OLD , false ]; - yield [ 'somewiki', MIGRATION_NEW , true ]; - } - - /** - * @dataProvider provideWikiIds - * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStore - */ - public function testGetRevisionStore( - $wikiId, - $mcrMigrationStage = MIGRATION_OLD, - $contentHandlerUseDb = true - ) { - $lbFactory = $this->getMockLoadBalancerFactory(); - $blobStoreFactory = $this->getMockBlobStoreFactory(); - $nameTableStoreFactory = $this->getNameTableStoreFactory(); - $slotRoleRegistry = $this->getMockSlotRoleRegistry(); - $cache = $this->getHashWANObjectCache(); - $commentStore = $this->getMockCommentStore(); - $actorMigration = ActorMigration::newMigration(); - $loggerProvider = $this->getMockLoggerSpi(); - - $factory = new RevisionStoreFactory( - $lbFactory, - $blobStoreFactory, - $nameTableStoreFactory, - $slotRoleRegistry, - $cache, - $commentStore, - $actorMigration, - $mcrMigrationStage, - $loggerProvider, - $contentHandlerUseDb - ); - - $store = $factory->getRevisionStore( $wikiId ); - $wrapper = TestingAccessWrapper::newFromObject( $store ); - - // ensure the correct object type is returned - $this->assertInstanceOf( RevisionStore::class, $store ); - - // ensure the RevisionStore is for the given wikiId - $this->assertSame( $wikiId, $wrapper->wikiId ); - - // ensure all other required services are correctly set - $this->assertSame( $cache, $wrapper->cache ); - $this->assertSame( $commentStore, $wrapper->commentStore ); - $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage ); - $this->assertSame( $actorMigration, $wrapper->actorMigration ); - $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() ); - - $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer ); - $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore ); - $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore ); - $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore ); - $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer - */ - private function getMockLoadBalancer() { - return $this->getMockBuilder( ILoadBalancer::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory - */ - private function getMockLoadBalancerFactory() { - $mock = $this->getMockBuilder( ILBFactory::class ) - ->disableOriginalConstructor()->getMock(); - - $mock->method( 'getMainLB' ) - ->willReturnCallback( function () { - return $this->getMockLoadBalancer(); - } ); - - return $mock; - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore - */ - private function getMockSqlBlobStore() { - return $this->getMockBuilder( SqlBlobStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory - */ - private function getMockBlobStoreFactory() { - $mock = $this->getMockBuilder( BlobStoreFactory::class ) - ->disableOriginalConstructor()->getMock(); - - $mock->method( 'newSqlBlobStore' ) - ->willReturnCallback( function () { - return $this->getMockSqlBlobStore(); - } ); - - return $mock; - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|SlotRoleRegistry - */ - private function getMockSlotRoleRegistry() { - $mock = $this->getMockBuilder( SlotRoleRegistry::class ) - ->disableOriginalConstructor()->getMock(); - - return $mock; - } - - /** - * @return NameTableStoreFactory - */ - private function getNameTableStoreFactory() { - return new NameTableStoreFactory( - $this->getMockLoadBalancerFactory(), - $this->getHashWANObjectCache(), - new NullLogger() ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore - */ - private function getMockCommentStore() { - return $this->getMockBuilder( CommentStore::class ) - ->disableOriginalConstructor()->getMock(); - } - - private function getHashWANObjectCache() { - return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi - */ - private function getMockLoggerSpi() { - $mock = $this->getMock( LoggerSpi::class ); - - $mock->method( 'getLogger' ) - ->willReturn( new NullLogger() ); - - return $mock; - } - -} diff --git a/tests/phpunit/includes/Revision/RevisionStoreTest.php b/tests/phpunit/includes/Revision/RevisionStoreTest.php index 5246e36832..0648bfce6e 100644 --- a/tests/phpunit/includes/Revision/RevisionStoreTest.php +++ b/tests/phpunit/includes/Revision/RevisionStoreTest.php @@ -16,7 +16,7 @@ use MediaWikiTestCase; use MWException; use Title; use WANObjectCache; -use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\TestingAccessWrapper; use WikitextContent; @@ -70,10 +70,10 @@ class RevisionStoreTest extends MediaWikiTestCase { } /** - * @return \PHPUnit_Framework_MockObject_MockObject|Database + * @return \PHPUnit_Framework_MockObject_MockObject|IDatabase */ private function getMockDatabase() { - return $this->getMockBuilder( Database::class ) + return $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor()->getMock(); } @@ -450,9 +450,12 @@ class RevisionStoreTest extends MediaWikiTestCase { } $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); + + $blobStore = new SqlBlobStore( $lb, $access, $cache ); - $blobStore = new SqlBlobStore( $lb, $cache ); $blobStore->setLegacyEncoding( $encoding, Language::factory( $locale ) ); $store = $this->getRevisionStore( $lb, $blobStore, $cache ); @@ -480,9 +483,11 @@ class RevisionStoreTest extends MediaWikiTestCase { ]; $cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); - $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $services = MediaWikiServices::getInstance(); + $lb = $services->getDBLoadBalancer(); + $access = $services->getExternalStoreAccess(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $blobStore->setLegacyEncoding( 'windows-1252', Language::factory( 'en' ) ); $store = $this->getRevisionStore( $lb, $blobStore, $cache ); diff --git a/tests/phpunit/includes/Revision/SlotRecordTest.php b/tests/phpunit/includes/Revision/SlotRecordTest.php index 1b6ff2aace..6495967ff4 100644 --- a/tests/phpunit/includes/Revision/SlotRecordTest.php +++ b/tests/phpunit/includes/Revision/SlotRecordTest.php @@ -77,7 +77,7 @@ class SlotRecordTest extends MediaWikiTestCase { $this->assertFalse( $record->isInherited() ); $this->assertSame( 'A', $record->getContent()->getText() ); $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); + $this->assertNotEmpty( $record->getSha1() ); $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); $this->assertSame( 2, $record->getRevision() ); $this->assertSame( 2, $record->getRevision() ); @@ -96,7 +96,7 @@ class SlotRecordTest extends MediaWikiTestCase { $this->assertFalse( $record->hasOrigin() ); $this->assertSame( 'A', $record->getContent()->getText() ); $this->assertSame( 1, $record->getSize() ); - $this->assertNotNull( $record->getSha1() ); + $this->assertNotEmpty( $record->getSha1() ); $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); $this->assertSame( 'myRole', $record->getRole() ); } @@ -177,6 +177,14 @@ class SlotRecordTest extends MediaWikiTestCase { $this->assertSame( $hash, $record->getSha1() ); } + public function testHashComputed() { + $row = $this->makeRow(); + $row->content_sha1 = ''; + + $rec = new SlotRecord( $row, new WikitextContent( 'A' ) ); + $this->assertNotEmpty( $rec->getSha1() ); + } + public function testNewWithSuppressedContent() { $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); $output = SlotRecord::newWithSuppressedContent( $input ); diff --git a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php deleted file mode 100644 index 67e9464f33..0000000000 --- a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::__construct - * @covers \MediaWiki\Revision\SlotRoleHandler::getRole() - * @covers \MediaWiki\Revision\SlotRoleHandler::getNameMessageKey() - * @covers \MediaWiki\Revision\SlotRoleHandler::getDefaultModel() - * @covers \MediaWiki\Revision\SlotRoleHandler::getOutputLayoutHints() - */ - public function testConstruction() { - $handler = new SlotRoleHandler( 'foo', 'FooModel', [ 'frob' => 'niz' ] ); - $this->assertSame( 'foo', $handler->getRole() ); - $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); - - $title = $this->makeBlankTitleObject(); - $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) ); - - $hints = $handler->getOutputLayoutHints(); - $this->assertArrayHasKey( 'frob', $hints ); - $this->assertSame( 'niz', $hints['frob'] ); - - $this->assertArrayHasKey( 'display', $hints ); - $this->assertArrayHasKey( 'region', $hints ); - $this->assertArrayHasKey( 'placement', $hints ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() - */ - public function testIsAllowedModel() { - $handler = new SlotRoleHandler( 'foo', 'FooModel' ); - - $title = $this->makeBlankTitleObject(); - $this->assertTrue( $handler->isAllowedModel( 'FooModel', $title ) ); - $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); - } - - /** - * @covers \MediaWiki\Revision\SlotRoleHandler::supportsArticleCount() - */ - public function testSupportsArticleCount() { - $handler = new SlotRoleHandler( 'foo', 'FooModel' ); - - $this->assertFalse( $handler->supportsArticleCount() ); - } - -} diff --git a/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php b/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php index 4d8030d519..c48a33a8af 100644 --- a/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php +++ b/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php @@ -17,13 +17,11 @@ use Wikimedia\Assert\PostconditionException; */ class SlotRoleRegistryTest extends MediaWikiTestCase { + /** + * @return Title + */ private function makeBlankTitleObject() { - /** @var Title $title */ - $title = $this->getMockBuilder( Title::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $title; + return $this->createMock( Title::class ); } private function makeNameTableStore( array $names = [] ) { diff --git a/tests/phpunit/includes/RevisionDbTestBase.php b/tests/phpunit/includes/RevisionDbTestBase.php index 7501167ebf..2d141e6ba6 100644 --- a/tests/phpunit/includes/RevisionDbTestBase.php +++ b/tests/phpunit/includes/RevisionDbTestBase.php @@ -1,4 +1,5 @@ setMwGlobals( - 'wgGroupPermissions', + $this->setGroupPermissions( [ 'sysop' => [ 'deletedtext' => true, @@ -1591,8 +1591,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase { * @covers Revision::userCan */ public function testUserCan( $bitField, $field, $userGroups, $expected ) { - $this->setMwGlobals( - 'wgGroupPermissions', + $this->setGroupPermissions( [ 'sysop' => [ 'deletedtext' => true, diff --git a/tests/phpunit/includes/RevisionMcrWriteBothDbTest.php b/tests/phpunit/includes/RevisionMcrWriteBothDbTest.php index 30a5484485..f9e33bcc37 100644 --- a/tests/phpunit/includes/RevisionMcrWriteBothDbTest.php +++ b/tests/phpunit/includes/RevisionMcrWriteBothDbTest.php @@ -1,4 +1,5 @@ getMockBuilder( LoadBalancer::class ) ->disableOriginalConstructor() ->getMock(); - + $access = MediaWikiServices::getInstance()->getExternalStoreAccess(); $cache = $this->getWANObjectCache(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); + return $blobStore; } @@ -807,7 +808,7 @@ class RevisionTest extends MediaWikiTestCase { public function testGetRevisionText_external_noOldId() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $this->assertSame( 'AAAABBAAA', @@ -829,14 +830,15 @@ class RevisionTest extends MediaWikiTestCase { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $lb = $this->getMockBuilder( LoadBalancer::class ) ->disableOriginalConstructor() ->getMock(); + $access = MediaWikiServices::getInstance()->getExternalStoreAccess(); - $blobStore = new SqlBlobStore( $lb, $cache ); + $blobStore = new SqlBlobStore( $lb, $access, $cache ); $this->setService( 'BlobStoreFactory', $this->mockBlobStoreFactory( $blobStore ) ); $this->assertSame( diff --git a/tests/phpunit/includes/ServiceWiringTest.php b/tests/phpunit/includes/ServiceWiringTest.php deleted file mode 100644 index 02e06f8dda..0000000000 --- a/tests/phpunit/includes/ServiceWiringTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertSame( $sortedServices, $services, - 'Please keep services sorted alphabetically' ); - } -} diff --git a/tests/phpunit/includes/SiteConfigurationTest.php b/tests/phpunit/includes/SiteConfigurationTest.php deleted file mode 100644 index 3b7226245f..0000000000 --- a/tests/phpunit/includes/SiteConfigurationTest.php +++ /dev/null @@ -1,379 +0,0 @@ -mConf = new SiteConfiguration; - - $this->mConf->suffixes = [ 'wikipedia' => 'wiki' ]; - $this->mConf->wikis = [ 'enwiki', 'dewiki', 'frwiki' ]; - $this->mConf->settings = [ - 'SimpleKey' => [ - 'wiki' => 'wiki', - 'tag' => 'tag', - 'enwiki' => 'enwiki', - 'dewiki' => 'dewiki', - 'frwiki' => 'frwiki', - ], - - 'Fallback' => [ - 'default' => 'default', - 'wiki' => 'wiki', - 'tag' => 'tag', - 'frwiki' => 'frwiki', - 'null_wiki' => null, - ], - - 'WithParams' => [ - 'default' => '$lang $site $wiki', - ], - - '+SomeGlobal' => [ - 'wiki' => [ - 'wiki' => 'wiki', - ], - 'tag' => [ - 'tag' => 'tag', - ], - 'enwiki' => [ - 'enwiki' => 'enwiki', - ], - 'dewiki' => [ - 'dewiki' => 'dewiki', - ], - 'frwiki' => [ - 'frwiki' => 'frwiki', - ], - ], - - 'MergeIt' => [ - '+wiki' => [ - 'wiki' => 'wiki', - ], - '+tag' => [ - 'tag' => 'tag', - ], - 'default' => [ - 'default' => 'default', - ], - '+enwiki' => [ - 'enwiki' => 'enwiki', - ], - '+dewiki' => [ - 'dewiki' => 'dewiki', - ], - '+frwiki' => [ - 'frwiki' => 'frwiki', - ], - ], - ]; - - $GLOBALS['SomeGlobal'] = [ 'SomeGlobal' => 'SomeGlobal' ]; - } - - /** - * This function is used as a callback within the tests below - */ - public static function getSiteParamsCallback( $conf, $wiki ) { - $site = null; - $lang = null; - foreach ( $conf->suffixes as $suffix ) { - if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { - $site = $suffix; - $lang = substr( $wiki, 0, -strlen( $suffix ) ); - break; - } - } - - return [ - 'suffix' => $site, - 'lang' => $lang, - 'params' => [ - 'lang' => $lang, - 'site' => $site, - 'wiki' => $wiki, - ], - 'tags' => [ 'tag' ], - ]; - } - - /** - * @covers SiteConfiguration::siteFromDB - */ - public function testSiteFromDb() { - $this->assertEquals( - [ 'wikipedia', 'en' ], - $this->mConf->siteFromDB( 'enwiki' ), - 'siteFromDB()' - ); - $this->assertEquals( - [ 'wikipedia', '' ], - $this->mConf->siteFromDB( 'wiki' ), - 'siteFromDB() on a suffix' - ); - $this->assertEquals( - [ null, null ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() on a non-existing wiki' - ); - - $this->mConf->suffixes = [ 'wiki', '' ]; - $this->assertEquals( - [ '', 'wikien' ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() on a non-existing wiki (2)' - ); - } - - /** - * @covers SiteConfiguration::getLocalDatabases - */ - public function testGetLocalDatabases() { - $this->assertEquals( - [ 'enwiki', 'dewiki', 'frwiki' ], - $this->mConf->getLocalDatabases(), - 'getLocalDatabases()' - ); - } - - /** - * @covers SiteConfiguration::get - */ - public function testGetConfVariables() { - // Simple - $this->assertEquals( - 'enwiki', - $this->mConf->get( 'SimpleKey', 'enwiki', 'wiki' ), - 'get(): simple setting on an existing wiki' - ); - $this->assertEquals( - 'dewiki', - $this->mConf->get( 'SimpleKey', 'dewiki', 'wiki' ), - 'get(): simple setting on an existing wiki (2)' - ); - $this->assertEquals( - 'frwiki', - $this->mConf->get( 'SimpleKey', 'frwiki', 'wiki' ), - 'get(): simple setting on an existing wiki (3)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'SimpleKey', 'wiki', 'wiki' ), - 'get(): simple setting on an suffix' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ), - 'get(): simple setting on an non-existing wiki' - ); - - // Fallback - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'enwiki', 'wiki' ), - 'get(): fallback setting on an existing wiki' - ); - $this->assertEquals( - 'tag', - $this->mConf->get( 'Fallback', 'dewiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an existing wiki (with wiki tag)' - ); - $this->assertEquals( - 'frwiki', - $this->mConf->get( 'Fallback', 'frwiki', 'wiki', [], [ 'tag' ] ), - 'get(): no fallback if wiki has its own setting (matching tag)' - ); - $this->assertSame( - // Potential regression test for T192855 - null, - $this->mConf->get( 'Fallback', 'null_wiki', 'wiki', [], [ 'tag' ] ), - 'get(): no fallback if wiki has its own setting (matching tag and uses null)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'wiki', 'wiki' ), - 'get(): fallback setting on an suffix' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'wiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an suffix (with wiki tag)' - ); - $this->assertEquals( - 'wiki', - $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ), - 'get(): fallback setting on an non-existing wiki' - ); - $this->assertEquals( - 'tag', - $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ), - 'get(): fallback setting on an non-existing wiki (with wiki tag)' - ); - - // Merging - $common = [ 'wiki' => 'wiki', 'default' => 'default' ]; - $commonTag = [ 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ]; - $this->assertEquals( - [ 'enwiki' => 'enwiki' ] + $common, - $this->mConf->get( 'MergeIt', 'enwiki', 'wiki' ), - 'get(): merging setting on an existing wiki' - ); - $this->assertEquals( - [ 'enwiki' => 'enwiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'enwiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (with tag)' - ); - $this->assertEquals( - [ 'dewiki' => 'dewiki' ] + $common, - $this->mConf->get( 'MergeIt', 'dewiki', 'wiki' ), - 'get(): merging setting on an existing wiki (2)' - ); - $this->assertEquals( - [ 'dewiki' => 'dewiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'dewiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (2) (with tag)' - ); - $this->assertEquals( - [ 'frwiki' => 'frwiki' ] + $common, - $this->mConf->get( 'MergeIt', 'frwiki', 'wiki' ), - 'get(): merging setting on an existing wiki (3)' - ); - $this->assertEquals( - [ 'frwiki' => 'frwiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'frwiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an existing wiki (3) (with tag)' - ); - $this->assertEquals( - [ 'wiki' => 'wiki' ] + $common, - $this->mConf->get( 'MergeIt', 'wiki', 'wiki' ), - 'get(): merging setting on an suffix' - ); - $this->assertEquals( - [ 'wiki' => 'wiki' ] + $commonTag, - $this->mConf->get( 'MergeIt', 'wiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an suffix (with tag)' - ); - $this->assertEquals( - $common, - $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ), - 'get(): merging setting on an non-existing wiki' - ); - $this->assertEquals( - $commonTag, - $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ), - 'get(): merging setting on an non-existing wiki (with tag)' - ); - } - - /** - * @covers SiteConfiguration::siteFromDB - */ - public function testSiteFromDbWithCallback() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $this->assertEquals( - [ 'wiki', 'en' ], - $this->mConf->siteFromDB( 'enwiki' ), - 'siteFromDB() with callback' - ); - $this->assertEquals( - [ 'wiki', '' ], - $this->mConf->siteFromDB( 'wiki' ), - 'siteFromDB() with callback on a suffix' - ); - $this->assertEquals( - [ null, null ], - $this->mConf->siteFromDB( 'wikien' ), - 'siteFromDB() with callback on a non-existing wiki' - ); - } - - /** - * @covers SiteConfiguration::get - */ - public function testParameterReplacement() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $this->assertEquals( - 'en wiki enwiki', - $this->mConf->get( 'WithParams', 'enwiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki' - ); - $this->assertEquals( - 'de wiki dewiki', - $this->mConf->get( 'WithParams', 'dewiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki (2)' - ); - $this->assertEquals( - 'fr wiki frwiki', - $this->mConf->get( 'WithParams', 'frwiki', 'wiki' ), - 'get(): parameter replacement on an existing wiki (3)' - ); - $this->assertEquals( - ' wiki wiki', - $this->mConf->get( 'WithParams', 'wiki', 'wiki' ), - 'get(): parameter replacement on an suffix' - ); - $this->assertEquals( - 'es wiki eswiki', - $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ), - 'get(): parameter replacement on an non-existing wiki' - ); - } - - /** - * @covers SiteConfiguration::getAll - */ - public function testGetAllGlobals() { - $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; - - $getall = [ - 'SimpleKey' => 'enwiki', - 'Fallback' => 'tag', - 'WithParams' => 'en wiki enwiki', - 'SomeGlobal' => [ 'enwiki' => 'enwiki' ] + $GLOBALS['SomeGlobal'], - 'MergeIt' => [ - 'enwiki' => 'enwiki', - 'tag' => 'tag', - 'wiki' => 'wiki', - 'default' => 'default' - ], - ]; - $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); - - $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); - - $this->assertEquals( - $getall['SimpleKey'], - $GLOBALS['SimpleKey'], - 'extractAllGlobals(): simple setting' - ); - $this->assertEquals( - $getall['Fallback'], - $GLOBALS['Fallback'], - 'extractAllGlobals(): fallback setting' - ); - $this->assertEquals( - $getall['WithParams'], - $GLOBALS['WithParams'], - 'extractAllGlobals(): parameter replacement' - ); - $this->assertEquals( - $getall['SomeGlobal'], - $GLOBALS['SomeGlobal'], - 'extractAllGlobals(): merging with global' - ); - $this->assertEquals( - $getall['MergeIt'], - $GLOBALS['MergeIt'], - 'extractAllGlobals(): merging setting' - ); - } -} diff --git a/tests/phpunit/includes/StatusTest.php b/tests/phpunit/includes/StatusTest.php index 6e62afdd4d..37ebf4cfcb 100644 --- a/tests/phpunit/includes/StatusTest.php +++ b/tests/phpunit/includes/StatusTest.php @@ -237,7 +237,7 @@ class StatusTest extends MediaWikiLangTestCase { } /** - * @param array $messageDetails E.g. array( 'KEY' => array(/PARAMS/) ) + * @param array $messageDetails E.g. [ 'KEY' => [ /PARAMS/ ] ] * @return Message[] */ protected function getMockMessages( $messageDetails ) { diff --git a/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php index 252c657867..0249730f1d 100644 --- a/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php +++ b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php @@ -13,34 +13,34 @@ use Wikimedia\TestingAccessWrapper; */ class BlobStoreFactoryTest extends MediaWikiTestCase { - public function provideWikiIds() { + public function provideDbDomains() { yield [ false ]; yield [ 'someWiki' ]; } /** - * @dataProvider provideWikiIds + * @dataProvider provideDbDomains */ - public function testNewBlobStore( $wikiId ) { + public function testNewBlobStore( $dbDomain ) { $factory = MediaWikiServices::getInstance()->getBlobStoreFactory(); - $store = $factory->newBlobStore( $wikiId ); + $store = $factory->newBlobStore( $dbDomain ); $this->assertInstanceOf( BlobStore::class, $store ); // This only works as we currently know this is a SqlBlobStore object $wrapper = TestingAccessWrapper::newFromObject( $store ); - $this->assertEquals( $wikiId, $wrapper->wikiId ); + $this->assertEquals( $dbDomain, $wrapper->dbDomain ); } /** - * @dataProvider provideWikiIds + * @dataProvider provideDbDomains */ - public function testNewSqlBlobStore( $wikiId ) { + public function testNewSqlBlobStore( $dbDomain ) { $factory = MediaWikiServices::getInstance()->getBlobStoreFactory(); - $store = $factory->newSqlBlobStore( $wikiId ); + $store = $factory->newSqlBlobStore( $dbDomain ); $this->assertInstanceOf( SqlBlobStore::class, $store ); $wrapper = TestingAccessWrapper::newFromObject( $store ); - $this->assertEquals( $wikiId, $wrapper->wikiId ); + $this->assertEquals( $dbDomain, $wrapper->dbDomain ); } } diff --git a/tests/phpunit/includes/Storage/NameTableStoreTest.php b/tests/phpunit/includes/Storage/NameTableStoreTest.php index ca87b49a37..47d3b92747 100644 --- a/tests/phpunit/includes/Storage/NameTableStoreTest.php +++ b/tests/phpunit/includes/Storage/NameTableStoreTest.php @@ -10,7 +10,7 @@ use MediaWiki\Storage\NameTableStore; use MediaWikiTestCase; use Psr\Log\NullLogger; use WANObjectCache; -use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\TestingAccessWrapper; @@ -57,37 +57,25 @@ class NameTableStoreTest extends MediaWikiTestCase { } private function getCallCheckingDb( $insertCalls, $selectCalls ) { - $mock = $this->getMockBuilder( Database::class ) + $proxiedMethods = [ + 'select' => $selectCalls, + 'insert' => $insertCalls, + 'affectedRows' => null, + 'insertId' => null, + 'getSessionLagStatus' => null, + 'writesPending' => null, + 'onTransactionPreCommitOrIdle' => null + ]; + $mock = $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor() ->getMock(); - $mock->expects( $this->exactly( $insertCalls ) ) - ->method( 'insert' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'insert' ], $args ); - } ); - $mock->expects( $this->exactly( $selectCalls ) ) - ->method( 'select' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'select' ], $args ); - } ); - $mock->expects( $this->exactly( $insertCalls ) ) - ->method( 'affectedRows' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'affectedRows' ], $args ); - } ); - $mock->expects( $this->any() ) - ->method( 'insertId' ) - ->willReturnCallback( function ( ...$args ) { - return call_user_func_array( [ $this->db, 'insertId' ], $args ); - } ); - $mock->expects( $this->any() ) - ->method( 'query' ) - ->willReturn( [] ); - $mock->expects( $this->any() ) - ->method( 'isOpen' ) - ->willReturn( true ); - $wrapper = TestingAccessWrapper::newFromObject( $mock ); - $wrapper->queryLogger = new NullLogger(); + foreach ( $proxiedMethods as $method => $count ) { + $mock->expects( is_int( $count ) ? $this->exactly( $count ) : $this->any() ) + ->method( $method ) + ->willReturnCallback( function ( ...$args ) use ( $method ) { + return call_user_func_array( [ $this->db, $method ], $args ); + } ); + } return $mock; } diff --git a/tests/phpunit/includes/Storage/PreparedEditTest.php b/tests/phpunit/includes/Storage/PreparedEditTest.php deleted file mode 100644 index 29999ee535..0000000000 --- a/tests/phpunit/includes/Storage/PreparedEditTest.php +++ /dev/null @@ -1,22 +0,0 @@ -parserOutputCallback = function () { - return new ParserOutput(); - }; - - $this->assertEquals( $output, $edit->getOutput() ); - $this->assertEquals( $output, $edit->output ); - } -} diff --git a/tests/phpunit/includes/Storage/SqlBlobStoreTest.php b/tests/phpunit/includes/Storage/SqlBlobStoreTest.php index 55069403d6..ac39b48cb9 100644 --- a/tests/phpunit/includes/Storage/SqlBlobStoreTest.php +++ b/tests/phpunit/includes/Storage/SqlBlobStoreTest.php @@ -24,6 +24,7 @@ class SqlBlobStoreTest extends MediaWikiTestCase { $store = new SqlBlobStore( $services->getDBLoadBalancer(), + $services->getExternalStoreAccess(), $services->getMainWANObjectCache() ); diff --git a/tests/phpunit/includes/TemplateCategoriesTest.php b/tests/phpunit/includes/TemplateCategoriesTest.php index ebd8dbd3da..04addab4a6 100644 --- a/tests/phpunit/includes/TemplateCategoriesTest.php +++ b/tests/phpunit/includes/TemplateCategoriesTest.php @@ -15,7 +15,7 @@ class TemplateCategoriesTest extends MediaWikiLangTestCase { */ public function testTemplateCategories() { $user = new User(); - $user->mRights = [ 'createpage', 'edit', 'purge', 'delete' ]; + $this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge', 'delete' ] ); $title = Title::newFromText( "Categorized from template" ); $page = WikiPage::factory( $title ); diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php index e50e1bcfd0..fd45732422 100644 --- a/tests/phpunit/includes/TestLogger.php +++ b/tests/phpunit/includes/TestLogger.php @@ -73,8 +73,8 @@ class TestLogger extends \Psr\Log\AbstractLogger { /** * Return the collected logs - * @return array Array of array( string $level, string $message ), or - * array( string $level, string $message, array $context ) if $collectContext was true. + * @return array Array of [ string $level, string $message ], or + * [ string $level, string $message, array $context ] if $collectContext was true. */ public function getBuffer() { return $this->buffer; diff --git a/tests/phpunit/includes/TitleArrayFromResultTest.php b/tests/phpunit/includes/TitleArrayFromResultTest.php index af49ecf73a..32c757101a 100644 --- a/tests/phpunit/includes/TitleArrayFromResultTest.php +++ b/tests/phpunit/includes/TitleArrayFromResultTest.php @@ -30,10 +30,6 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { return $row; } - private function getTitleArrayFromResult( $resultWrapper ) { - return new TitleArrayFromResult( $resultWrapper ); - } - /** * @covers TitleArrayFromResult::__construct */ @@ -41,7 +37,7 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { $row = false; $resultWrapper = $this->getMockResultWrapper( $row ); - $object = $this->getTitleArrayFromResult( $resultWrapper ); + $object = new TitleArrayFromResult( $resultWrapper ); $this->assertEquals( $resultWrapper, $object->res ); $this->assertSame( 0, $object->key ); @@ -57,7 +53,7 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { $row = $this->getRowWithTitle( $namespace, $title ); $resultWrapper = $this->getMockResultWrapper( $row ); - $object = $this->getTitleArrayFromResult( $resultWrapper ); + $object = new TitleArrayFromResult( $resultWrapper ); $this->assertEquals( $resultWrapper, $object->res ); $this->assertSame( 0, $object->key ); @@ -79,7 +75,7 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { * @covers TitleArrayFromResult::count */ public function testCountWithVaryingValues( $numRows ) { - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( + $object = new TitleArrayFromResult( $this->getMockResultWrapper( $this->getRowWithTitle(), $numRows ) ); @@ -93,7 +89,7 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { $namespace = 0; $title = 'foo'; $row = $this->getRowWithTitle( $namespace, $title ); - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $row ) ); + $object = new TitleArrayFromResult( $this->getMockResultWrapper( $row ) ); $this->assertInstanceOf( Title::class, $object->current() ); $this->assertEquals( $namespace, $object->current->mNamespace ); $this->assertEquals( $title, $object->current->mTextform ); @@ -111,7 +107,7 @@ class TitleArrayFromResultTest extends PHPUnit\Framework\TestCase { * @covers TitleArrayFromResult::valid */ public function testValid( $input, $expected ) { - $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $input ) ); + $object = new TitleArrayFromResult( $this->getMockResultWrapper( $input ) ); $this->assertEquals( $expected, $object->valid() ); } diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php index abd70b2524..77d6f59d21 100644 --- a/tests/phpunit/includes/TitleMethodsTest.php +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -437,6 +437,31 @@ class TitleMethodsTest extends MediaWikiLangTestCase { $this->assertSame( $expected, $title->getLinkURL( $query, $query2, $proto ) ); } + /** + * Integration test to catch regressions like T74870. Taken and modified + * from SemanticMediaWiki + * + * @covers Title::moveTo + */ + public function testTitleMoveCompleteIntegrationTest() { + $this->hideDeprecated( 'Title::moveTo' ); + + $oldTitle = Title::newFromText( 'Help:Some title' ); + WikiPage::factory( $oldTitle )->doEditContent( new WikitextContent( 'foo' ), 'bar' ); + $newTitle = Title::newFromText( 'Help:Some other title' ); + $this->assertNull( + WikiPage::factory( $newTitle )->getRevision() + ); + + $this->assertTrue( $oldTitle->moveTo( $newTitle, false, 'test1', true ) ); + $this->assertNotNull( + WikiPage::factory( $oldTitle )->getRevision() + ); + $this->assertNotNull( + WikiPage::factory( $newTitle )->getRevision() + ); + } + function tearDown() { Title::clearCaches(); parent::tearDown(); diff --git a/tests/phpunit/includes/TitlePermissionTest.php b/tests/phpunit/includes/TitlePermissionTest.php index 1f8011d463..e6cf8c8b89 100644 --- a/tests/phpunit/includes/TitlePermissionTest.php +++ b/tests/phpunit/includes/TitlePermissionTest.php @@ -1,5 +1,6 @@ user = $this->userUser; } - $this->overrideMwServices(); - } - - protected function setUserPerm( $perm ) { - // Setting member variables is evil!!! - - if ( is_array( $perm ) ) { - $this->user->mRights = $perm; - } else { - $this->user->mRights = [ $perm ]; - } + $this->resetServices(); } protected function setTitle( $ns, $title = "Main_Page" ) { @@ -113,139 +104,139 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setUser( 'anon' ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ "nocreatetext" ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreatetext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_TALK ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createpage" ); + $this->overrideUserPermissions( $this->user, "createpage" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "createtalk" ); + $this->overrideUserPermissions( $this->user, "createtalk" ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); $this->assertEquals( [ [ 'nocreate-loggedin' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'cant-move-user-page' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setTitle( NS_USER, $this->userName . '/subpage' ); - $this->setUserPerm( "move-rootuserpages" ); + $this->overrideUserPermissions( $this->user, "move-rootuserpages" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); + $this->overrideUserPermissions( $this->user, "movefile" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); $this->setUser( 'anon' ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenotallowedfile' ], [ 'movenologintext' ] ], $res ); $this->setTitle( NS_FILE, "img.png" ); - $this->setUserPerm( "movefile" ); + $this->overrideUserPermissions( $this->user, "movefile" ); $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); $this->assertEquals( [ [ 'movenologintext' ] ], $res ); $this->setUser( $this->userName ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ] ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ] ] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->runGroupPermissions( 'move', [ [ 'movenotallowedfile' ], [ 'movenotallowed' ] ], @@ -258,51 +249,51 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->setUser( 'anon' ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [] ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ], [ [ 'movenologintext' ] ] ); $this->setUser( $this->userName ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user, "" ); $this->runGroupPermissions( 'move', [ [ 'movenotallowed' ] ] ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $this->runGroupPermissions( 'move', [] ); $this->setUser( 'anon' ); - $this->setUserPerm( 'move' ); + $this->overrideUserPermissions( $this->user, 'move' ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [ [ 'movenotallowed' ] ], $res ); } $this->setTitle( NS_USER ); $this->setUser( $this->userName ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [ [ 'cant-move-to-user-page' ] ], $res ); $this->setUser( 'anon' ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); $this->setTitle( NS_USER, "User/subpage" ); - $this->setUserPerm( [ "move", "move-rootuserpages" ] ); + $this->overrideUserPermissions( $this->user, [ "move", "move-rootuserpages" ] ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); - $this->setUserPerm( "move" ); + $this->overrideUserPermissions( $this->user, "move" ); $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); $this->assertEquals( [], $res ); @@ -328,7 +319,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ]; foreach ( [ "edit", "protect", "" ] as $action ) { - $this->setUserPerm( null ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( $check[$action][0], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][0], @@ -340,15 +331,19 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $old = $wgGroupPermissions; $wgGroupPermissions = []; + $this->resetServices(); + $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, 'full' ) ); $this->assertEquals( $check[$action][1], $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) ); + $wgGroupPermissions = $old; + $this->resetServices(); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][2], $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); $this->assertEquals( $check[$action][2], @@ -356,7 +351,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->assertEquals( $check[$action][2], $this->title->getUserPermissionsErrors( $action, $this->user, 'secure' ) ); - $this->setUserPerm( $action ); + $this->overrideUserPermissions( $this->user, $action ); $this->assertEquals( $check[$action][3], $this->title->userCan( $action, $this->user, true ) ); $this->assertEquals( $check[$action][3], @@ -372,23 +367,39 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $result2 = $result; } + // XXX: there could be a better way to handle this, but since we need to + // override PermissionManager service each time globals are changed + // and in the same time we need to keep user permissions overrides from the outside + // the best we can do inside this method is to save & restore faked user perms + + $userPermsOverrides = MediaWikiServices::getInstance()->getPermissionManager() + ->getUserPermissions( $this->user ); + $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = false; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = false; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = true; $wgGroupPermissions['user']['move'] = true; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); $wgGroupPermissions['autoconfirmed']['move'] = false; $wgGroupPermissions['user']['move'] = true; + $this->resetServices(); + $this->overrideUserPermissions( $this->user, $userPermsOverrides ); $res = $this->title->getUserPermissionsErrors( $action, $this->user ); $this->assertEquals( $result2, $res ); } @@ -408,42 +419,42 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MAIN ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $wgNamespaceProtection[NS_USER] = [ 'bogus' ]; $this->setTitle( NS_USER ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'namespaceprotected', 'User', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->setTitle( NS_MEDIAWIKI ); - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [ [ 'protectedinterface', 'bogus' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $wgNamespaceProtection = null; - $this->setUserPerm( 'bogus' ); + $this->overrideUserPermissions( $this->user, 'bogus' ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->assertEquals( true, $this->title->userCan( 'bogus', $this->user ) ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -644,39 +655,39 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $resultUserJs, $resultPatrol ) { - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultNone, $result ); - $this->setUserPerm( 'editmyusercss' ); + $this->overrideUserPermissions( $this->user, 'editmyusercss' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyCss, $result ); - $this->setUserPerm( 'editmyuserjson' ); + $this->overrideUserPermissions( $this->user, 'editmyuserjson' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyJson, $result ); - $this->setUserPerm( 'editmyuserjs' ); + $this->overrideUserPermissions( $this->user, 'editmyuserjs' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultMyJs, $result ); - $this->setUserPerm( 'editusercss' ); + $this->overrideUserPermissions( $this->user, 'editusercss' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserCss, $result ); - $this->setUserPerm( 'edituserjson' ); + $this->overrideUserPermissions( $this->user, 'edituserjson' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserJson, $result ); - $this->setUserPerm( 'edituserjs' ); + $this->overrideUserPermissions( $this->user, 'edituserjs' ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( $resultUserJs, $result ); - $this->setUserPerm( '' ); + $this->overrideUserPermissions( $this->user ); $result = $this->title->getUserPermissionsErrors( 'patrol', $this->user ); $this->assertEquals( reset( $resultPatrol[0] ), reset( $result[0] ) ); - $this->setUserPerm( [ 'edituserjs', 'edituserjson', 'editusercss' ] ); + $this->overrideUserPermissions( $this->user, [ 'edituserjs', 'edituserjson', 'editusercss' ] ); $result = $this->title->getUserPermissionsErrors( 'bogus', $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ] ], $result ); } @@ -696,7 +707,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->setTitle( NS_MAIN ); $this->title->mRestrictionsLoaded = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->title->mRestrictions = [ "bogus" => [ 'bogus', "sysop", "protect", "" ] ]; $this->assertEquals( [], @@ -719,7 +730,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'protect', 'edit' ] ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( "" ); + $this->overrideUserPermissions( $this->user ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'editprotected', 'bogus' ], @@ -732,7 +743,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { [ 'protectedpagetext', 'protect', 'edit' ] ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( [ [ 'badaccess-group0' ], [ 'protectedpagetext', 'bogus', 'bogus' ], [ 'protectedpagetext', 'protect', 'bogus' ] ], @@ -745,7 +756,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->user ) ); $this->title->mCascadeRestriction = true; - $this->setUserPerm( "edit" ); + $this->overrideUserPermissions( $this->user, "edit" ); $this->assertEquals( false, $this->title->quickUserCan( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -762,7 +773,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); - $this->setUserPerm( [ "edit", "editprotected" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "editprotected" ] ); $this->assertEquals( false, $this->title->quickUserCan( 'bogus', $this->user ) ); $this->assertEquals( false, @@ -785,7 +796,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { */ public function testCascadingSourcesRestrictions() { $this->setTitle( NS_MAIN, "test page" ); - $this->setUserPerm( [ "edit", "bogus" ] ); + $this->overrideUserPermissions( $this->user, [ "edit", "bogus" ] ); $this->title->mCascadeSources = [ Title::makeTitle( NS_MAIN, "Bogus" ), @@ -815,7 +826,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { * @covers \MediaWiki\Permissions\PermissionManager::checkActionPermissions */ public function testActionPermissions() { - $this->setUserPerm( [ "createpage" ] ); + $this->overrideUserPermissions( $this->user, [ "createpage" ] ); $this->setTitle( NS_MAIN, "test page" ); $this->title->mTitleProtection['permission'] = ''; $this->title->mTitleProtection['user'] = $this->user->getId(); @@ -829,26 +840,26 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->userCan( 'create', $this->user ) ); $this->title->mTitleProtection['permission'] = 'editprotected'; - $this->setUserPerm( [ 'createpage', 'protect' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'protect' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( false, $this->title->userCan( 'create', $this->user ) ); - $this->setUserPerm( [ 'createpage', 'editprotected' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage', 'editprotected' ] ); $this->assertEquals( [], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( true, $this->title->userCan( 'create', $this->user ) ); - $this->setUserPerm( [ 'createpage' ] ); + $this->overrideUserPermissions( $this->user, [ 'createpage' ] ); $this->assertEquals( [ [ 'titleprotected', 'Useruser', 'test' ] ], $this->title->getUserPermissionsErrors( 'create', $this->user ) ); $this->assertEquals( false, $this->title->userCan( 'create', $this->user ) ); $this->setTitle( NS_MEDIA, "test page" ); - $this->setUserPerm( [ "move" ] ); + $this->overrideUserPermissions( $this->user, [ "move" ] ); $this->assertEquals( false, $this->title->userCan( 'move', $this->user ) ); $this->assertEquals( [ [ 'immobile-source-namespace', 'Media' ] ], @@ -894,9 +905,12 @@ class TitlePermissionTest extends MediaWikiLangTestCase { 'wgEmailAuthentication' => true, 'wgBlockDisablesLogin' => false, ] ); - $this->overrideMwServices(); + $this->resetServices(); - $this->setUserPerm( [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] ); + $this->overrideUserPermissions( + $this->user, + [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] + ); $this->setTitle( NS_HELP, "test page" ); # $wgEmailConfirmToEdit only applies to 'edit' action @@ -906,7 +920,11 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); $this->setMwGlobals( 'wgEmailConfirmToEdit', false ); - $this->overrideMwServices(); + $this->resetServices(); + $this->overrideUserPermissions( + $this->user, + [ 'createpage', 'edit', 'move', 'rollback', 'patrol', 'upload', 'purge' ] + ); $this->assertNotContains( [ 'confirmedittext' ], $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); @@ -920,7 +938,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { $prev = time(); $now = time() + 120; $this->user->mBlockedby = $this->user->getId(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -930,8 +948,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $this->user->mBlock->setTimestamp( 0 ); $this->assertEquals( [ [ 'autoblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ] ], $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); @@ -943,7 +961,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { global $wgLocalTZoffset; $wgLocalTZoffset = -60; $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -952,8 +970,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { 'expiry' => 10, ] ); $this->assertEquals( [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ], $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) @@ -970,8 +988,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'systemblockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', 'test', 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", 'test', 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, @@ -989,7 +1007,7 @@ class TitlePermissionTest extends MediaWikiLangTestCase { // partial block message test $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1016,8 +1034,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext-partial', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, '23:00, 31 December 1969', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, @@ -1070,10 +1088,11 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ], ], ] ); + $this->resetServices(); $now = time(); $this->user->mBlockedby = $this->user->getName(); - $this->user->mBlock = new Block( [ + $this->user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $this->user->getId(), 'reason' => 'no reason given', @@ -1083,8 +1102,8 @@ class TitlePermissionTest extends MediaWikiLangTestCase { ] ); $errors = [ [ 'blockedtext', - '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', - 'Useruser', null, 'infinite', '127.0.8.1', + "[[User:Useruser|\u{202A}Useruser\u{202C}]]", 'no reason given', '127.0.0.1', + "\u{202A}Useruser\u{202C}", null, 'infinite', '127.0.8.1', $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ] ]; $this->assertEquals( $errors, diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php index c46f69b2ef..4ffef02d19 100644 --- a/tests/phpunit/includes/TitleTest.php +++ b/tests/phpunit/includes/TitleTest.php @@ -295,6 +295,8 @@ class TitleTest extends MediaWikiTestCase { * @covers Title::isValidMoveOperation */ public function testIsValidMoveOperation( $source, $target, $expected ) { + $this->hideDeprecated( 'Title::isValidMoveOperation' ); + $this->setMwGlobals( 'wgContentHandlerUseDB', false ); $title = Title::newFromText( $source ); $nt = Title::newFromText( $target ); @@ -336,7 +338,7 @@ class TitleTest extends MediaWikiTestCase { public function testWgWhitelistReadRegexp( $whitelistRegexp, $source, $action, $expected ) { // $wgWhitelistReadRegexp must be an array. Since the provided test cases // usually have only one regex, it is more concise to write the lonely regex - // as a string. Thus we cast to an array() to honor $wgWhitelistReadRegexp + // as a string. Thus we cast to a [] to honor $wgWhitelistReadRegexp // type requisite. if ( is_string( $whitelistRegexp ) ) { $whitelistRegexp = [ $whitelistRegexp ]; @@ -353,7 +355,7 @@ class TitleTest extends MediaWikiTestCase { // New anonymous user with no rights $user = new User; - $user->mRights = []; + $this->overrideUserPermissions( $user, [] ); $errors = $title->userCan( $action, $user ); if ( is_bool( $expected ) ) { @@ -492,17 +494,31 @@ class TitleTest extends MediaWikiTestCase { */ public function testGetBaseText( $title, $expected, $msg = '' ) { $title = Title::newFromText( $title ); - $this->assertEquals( $expected, + $this->assertSame( $expected, $title->getBaseText(), $msg ); } + /** + * @dataProvider provideBaseTitleCases + * @covers Title::getBaseTitle + */ + public function testGetBaseTitle( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $base = $title->getBaseTitle(); + $this->assertTrue( $base->isValid(), $msg ); + $this->assertTrue( + $base->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) ), + $msg + ); + } + public static function provideBaseTitleCases() { return [ # Title, expected base, optional message [ 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ], - [ 'User:Foo/Bar/Baz', 'Foo/Bar' ], + [ 'User:Foo / Bar / Baz', 'Foo / Bar ' ], ]; } @@ -518,11 +534,25 @@ class TitleTest extends MediaWikiTestCase { ); } + /** + * @dataProvider provideRootTitleCases + * @covers Title::getRootTitle + */ + public function testGetRootTitle( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $root = $title->getRootTitle(); + $this->assertTrue( $root->isValid(), $msg ); + $this->assertTrue( + $root->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) ), + $msg + ); + } + public static function provideRootTitleCases() { return [ # Title, expected base, optional message [ 'User:John_Doe/subOne/subTwo', 'John Doe' ], - [ 'User:Foo/Bar/Baz', 'Foo' ], + [ 'User:Foo / Bar / Baz', 'Foo ' ], ]; } @@ -707,6 +737,12 @@ class TitleTest extends MediaWikiTestCase { [ Title::makeTitle( NS_MAIN, '|' ), false ], [ Title::makeTitle( NS_MAIN, '#' ), false ], [ Title::makeTitle( NS_MAIN, 'Test' ), true ], + [ Title::makeTitle( NS_MAIN, ' Test' ), false ], + [ Title::makeTitle( NS_MAIN, '_Test' ), false ], + [ Title::makeTitle( NS_MAIN, 'Test ' ), false ], + [ Title::makeTitle( NS_MAIN, 'Test_' ), false ], + [ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ], + [ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ], [ Title::makeTitle( -33, 'Test' ), false ], [ Title::makeTitle( 77663399, 'Test' ), false ], ]; @@ -764,6 +800,65 @@ class TitleTest extends MediaWikiTestCase { 'Virtual namespace cannot have talk page' => [ Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false ], + 'Relative link has no talk page' => [ + Title::makeTitle( NS_MAIN, '', 'Kittens' ), false + ], + 'Interwiki link has no talk page' => [ + Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false + ], + ]; + } + + public function provideIsWatchable() { + return [ + 'User page is watchable' => [ + Title::makeTitle( NS_USER, 'Jane' ), true + ], + 'Talke page is watchable' => [ + Title::makeTitle( NS_TALK, 'Foo' ), true + ], + 'Special page is not watchable' => [ + Title::makeTitle( NS_SPECIAL, 'Thing' ), false + ], + 'Virtual namespace is not watchable' => [ + Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false + ], + 'Relative link is not watchable' => [ + Title::makeTitle( NS_MAIN, '', 'Kittens' ), false + ], + 'Interwiki link is not watchable' => [ + Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false + ], + ]; + } + + public static function provideGetTalkPage_good() { + return [ + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + ]; + } + + public static function provideGetTalkPage_bad() { + return [ + [ Title::makeTitle( NS_SPECIAL, 'Test' ) ], + [ Title::makeTitle( NS_MEDIA, 'Test' ) ], + [ Title::makeTitle( NS_MAIN, '', 'Kittens' ) ], + [ Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ) ], + ]; + } + + public static function provideGetSubjectPage_good() { + return [ + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], + ]; + } + + public static function provideGetOtherPage_good() { + return [ + [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], + [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ], ]; } @@ -779,31 +874,44 @@ class TitleTest extends MediaWikiTestCase { $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() ); } - public static function provideGetTalkPage_good() { - return [ - [ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], - [ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ], - ]; + /** + * @dataProvider provideIsWatchable + * @covers Title::isWatchable + * + * @param Title $title + * @param bool $expected + */ + public function testIsWatchable( Title $title, $expected ) { + $actual = $title->canHaveTalkPage(); + $this->assertSame( $expected, $actual, $title->getPrefixedDBkey() ); } /** * @dataProvider provideGetTalkPage_good * @covers Title::getTalkPageIfDefined */ - public function testGetTalkPageIfDefined_good( Title $title ) { - $talk = $title->getTalkPageIfDefined(); - $this->assertInstanceOf( - Title::class, - $talk, - $title->getPrefixedDBKey() - ); + public function testGetTalkPage_good( Title $title, Title $expected ) { + $actual = $title->getTalkPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); } - public static function provideGetTalkPage_bad() { - return [ - [ Title::makeTitle( NS_SPECIAL, 'Test' ) ], - [ Title::makeTitle( NS_MEDIA, 'Test' ) ], - ]; + /** + * @dataProvider provideGetTalkPage_bad + * @covers Title::getTalkPageIfDefined + */ + public function testGetTalkPage_bad( Title $title ) { + $this->setExpectedException( MWException::class ); + $title->getTalkPage(); + } + + /** + * @dataProvider provideGetTalkPage_good + * @covers Title::getTalkPageIfDefined + */ + public function testGetTalkPageIfDefined_good( Title $title, Title $expected ) { + $actual = $title->getTalkPageIfDefined(); + $this->assertNotNull( $actual, $title->getPrefixedDBkey() ); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); } /** @@ -814,10 +922,37 @@ class TitleTest extends MediaWikiTestCase { $talk = $title->getTalkPageIfDefined(); $this->assertNull( $talk, - $title->getPrefixedDBKey() + $title->getPrefixedDBkey() ); } + /** + * @dataProvider provideGetSubjectPage_good + * @covers Title::getSubjectPage + */ + public function testGetSubjectPage_good( Title $title, Title $expected ) { + $actual = $title->getSubjectPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); + } + + /** + * @dataProvider provideGetOtherPage_good + * @covers Title::getOtherPage + */ + public function testGetOtherPage_good( Title $title, Title $expected ) { + $actual = $title->getOtherPage(); + $this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() ); + } + + /** + * @dataProvider provideGetTalkPage_bad + * @covers Title::getOtherPage + */ + public function testGetOtherPage_bad( Title $title ) { + $this->setExpectedException( MWException::class ); + $title->getOtherPage(); + } + public function provideCreateFragmentTitle() { return [ [ Title::makeTitle( NS_MAIN, 'Test' ), 'foo' ], diff --git a/tests/phpunit/includes/WebRequestTest.php b/tests/phpunit/includes/WebRequestTest.php index cda56603f9..0d5c59b0de 100644 --- a/tests/phpunit/includes/WebRequestTest.php +++ b/tests/phpunit/includes/WebRequestTest.php @@ -391,7 +391,7 @@ class WebRequestTest extends MediaWikiTestCase { * @dataProvider provideGetIP * @covers WebRequest::getIP */ - public function testGetIP( $expected, $input, $squid, $xffList, $private, $description ) { + public function testGetIP( $expected, $input, $cdn, $xffList, $private, $description ) { $this->setServerVars( $input ); $this->setMwGlobals( [ 'wgUsePrivateIPs' => $private, @@ -405,7 +405,7 @@ class WebRequestTest extends MediaWikiTestCase { ] ] ); - $this->setService( 'ProxyLookup', new ProxyLookup( [], $squid ) ); + $this->setService( 'ProxyLookup', new ProxyLookup( [], $cdn ) ); $request = new WebRequest(); $result = $request->getIP(); @@ -587,8 +587,8 @@ class WebRequestTest extends MediaWikiTestCase { public function testGetIpLackOfRemoteAddrThrowAnException() { // ensure that local install state doesn't interfere with test $this->setMwGlobals( [ - 'wgSquidServersNoPurge' => [], - 'wgSquidServers' => [], + 'wgCdnServers' => [], + 'wgCdnServersNoPurge' => [], 'wgUsePrivateIPs' => false, 'wgHooks' => [], ] ); diff --git a/tests/phpunit/includes/WikiMapTest.php b/tests/phpunit/includes/WikiMapTest.php index 1608b9c955..6850a24545 100644 --- a/tests/phpunit/includes/WikiMapTest.php +++ b/tests/phpunit/includes/WikiMapTest.php @@ -1,4 +1,5 @@ select = new XmlSelect(); - } - - protected function tearDown() { - parent::tearDown(); - $this->select = null; - } - - /** - * @covers XmlSelect::__construct - */ - public function testConstructWithoutParameters() { - $this->assertEquals( '', $this->select->getHTML() ); - } - - /** - * Parameters are $name (false), $id (false), $default (false) - * @dataProvider provideConstructionParameters - * @covers XmlSelect::__construct - */ - public function testConstructParameters( $name, $id, $default, $expected ) { - $this->select = new XmlSelect( $name, $id, $default ); - $this->assertEquals( $expected, $this->select->getHTML() ); - } - - /** - * Provide parameters for testConstructParameters() which use three - * parameters: - * - $name (default: false) - * - $id (default: false) - * - $default (default: false) - * Provides a fourth parameters representing the expected HTML output - */ - public static function provideConstructionParameters() { - return [ - /** - * Values are set following a 3-bit Gray code where two successive - * values differ by only one value. - * See https://en.wikipedia.org/wiki/Gray_code - */ - # $name $id $default - [ false, false, false, '' ], - [ false, false, 'foo', '' ], - [ false, 'id', 'foo', '' ], - [ false, 'id', false, '' ], - [ 'name', 'id', false, '' ], - [ 'name', 'id', 'foo', '' ], - [ 'name', false, 'foo', '' ], - [ 'name', false, false, '' ], - ]; - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOption() { - $this->select->addOption( 'foo' ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithDefault() { - $this->select->addOption( 'foo', true ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithFalse() { - $this->select->addOption( 'foo', false ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::addOption - */ - public function testAddOptionWithValueZero() { - $this->select->addOption( 'foo', 0 ); - $this->assertEquals( - '', - $this->select->getHTML() - ); - } - - /** - * @covers XmlSelect::setDefault - */ - public function testSetDefault() { - $this->select->setDefault( 'bar1' ); - $this->select->addOption( 'foo1' ); - $this->select->addOption( 'bar1' ); - $this->select->addOption( 'foo2' ); - $this->assertEquals( - '', $this->select->getHTML() ); - } - - /** - * Adding default later on should set the correct selection or - * raise an exception. - * To handle this, we need to render the options in getHtml() - * @covers XmlSelect::setDefault - */ - public function testSetDefaultAfterAddingOptions() { - $this->select->addOption( 'foo1' ); - $this->select->addOption( 'bar1' ); - $this->select->addOption( 'foo2' ); - $this->select->setDefault( 'bar1' ); # setting default after adding options - $this->assertEquals( - '', $this->select->getHTML() ); - } - - /** - * @covers XmlSelect::setAttribute - * @covers XmlSelect::getAttribute - */ - public function testGetAttributes() { - # create some attributes - $this->select->setAttribute( 'dummy', 0x777 ); - $this->select->setAttribute( 'string', 'euro €' ); - $this->select->setAttribute( 1911, 'razor' ); - - # verify we can retrieve them - $this->assertEquals( - $this->select->getAttribute( 'dummy' ), - 0x777 - ); - $this->assertEquals( - $this->select->getAttribute( 'string' ), - 'euro €' - ); - $this->assertEquals( - $this->select->getAttribute( 1911 ), - 'razor' - ); - - # inexistent keys should give us 'null' - $this->assertEquals( - $this->select->getAttribute( 'I DO NOT EXIT' ), - null - ); - - # verify string / integer - $this->assertEquals( - $this->select->getAttribute( '1911' ), - 'razor' - ); - $this->assertEquals( - $this->select->getAttribute( 'dummy' ), - 0x777 - ); - } -} diff --git a/tests/phpunit/includes/actions/ActionTest.php b/tests/phpunit/includes/actions/ActionTest.php index d80d627b4f..4d977cbf1e 100644 --- a/tests/phpunit/includes/actions/ActionTest.php +++ b/tests/phpunit/includes/actions/ActionTest.php @@ -1,5 +1,6 @@ getTestUser()->getUser(); - $user->mRights = [ 'access' ]; + $this->overrideUserPermissions( $user, 'access' ); $action = Action::factory( 'access', $this->getPage(), $this->getContext() ); $this->assertNull( $action->canExecute( $user ) ); } public function testCanExecuteNoRight() { $user = $this->getTestUser()->getUser(); - $user->mRights = []; + $this->overrideUserPermissions( $user, [] ); $action = Action::factory( 'access', $this->getPage(), $this->getContext() ); try { @@ -208,12 +209,12 @@ class ActionTest extends MediaWikiTestCase { public function testCanExecuteRequiresUnblock() { $user = $this->getTestUser()->getUser(); - $user->mRights = []; + $this->overrideUserPermissions( $user, [] ); $page = $this->getExistingTestPage(); $action = Action::factory( 'unblock', $page, $this->getContext() ); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $user, 'by' => $this->getTestSysop()->getUser()->getId(), 'expiry' => 'infinity', diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php index 4e19822d94..6a44ff3594 100644 --- a/tests/phpunit/includes/api/ApiBaseTest.php +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -1,5 +1,6 @@ getMutableTestUser()->getUser(); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $user->getName(), 'user' => $user->getID(), 'by' => $this->getTestSysop()->getUser()->getId(), @@ -1336,7 +1337,7 @@ class ApiBaseTest extends ApiTestCase { $userInfoTrait = TestingAccessWrapper::newFromObject( $this->getMockForTrait( ApiBlockInfoTrait::class ) ); - $blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockInfo( $block ) ]; + $blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockDetails( $block ) ]; $expect = Status::newGood(); $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) ); @@ -1383,7 +1384,7 @@ class ApiBaseTest extends ApiTestCase { // Has a blocked $user, so special block handling $user = $this->getMutableTestUser()->getUser(); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $user->getName(), 'user' => $user->getID(), 'by' => $this->getTestSysop()->getUser()->getId(), @@ -1394,7 +1395,7 @@ class ApiBaseTest extends ApiTestCase { $userInfoTrait = TestingAccessWrapper::newFromObject( $this->getObjectForTrait( ApiBlockInfoTrait::class ) ); - $blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockInfo( $block ) ]; + $blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockDetails( $block ) ]; $expect = Status::newGood(); $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) ); diff --git a/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php b/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php index 932495a6c2..ba5c003776 100644 --- a/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php +++ b/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php @@ -1,6 +1,7 @@ getMockForTrait( ApiBlockInfoTrait::class ); - $info = TestingAccessWrapper::newFromObject( $mock )->getBlockInfo( $block ); + $info = TestingAccessWrapper::newFromObject( $mock )->getBlockDetails( $block ); $subset = array_merge( [ 'blockid' => null, 'blockedby' => '', @@ -23,14 +24,14 @@ class ApiBlockInfoTraitTest extends MediaWikiTestCase { $this->assertArraySubset( $subset, $info ); } - public static function provideGetBlockInfo() { + public static function provideGetBlockDetails() { return [ 'Sitewide block' => [ - new Block(), + new DatabaseBlock(), [ 'blockpartial' => false ], ], 'Partial block' => [ - new Block( [ 'sitewide' => false ] ), + new DatabaseBlock( [ 'sitewide' => false ] ), [ 'blockpartial' => true ], ], 'System block' => [ diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php index 7274a546ee..b29d333cb2 100644 --- a/tests/phpunit/includes/api/ApiBlockTest.php +++ b/tests/phpunit/includes/api/ApiBlockTest.php @@ -1,5 +1,6 @@ mUser = $this->getMutableTestUser()->getUser(); + $this->setMwGlobals( 'wgBlockCIDRLimit', [ + 'IPv4' => 16, + 'IPv6' => 19, + ] ); } protected function getTokens() { @@ -40,7 +45,6 @@ class ApiBlockTest extends ApiTestCase { $tokens = $this->getTokens(); $this->assertNotNull( $this->mUser, 'Sanity check' ); - $this->assertNotSame( 0, $this->mUser->getId(), 'Sanity check' ); $this->assertArrayHasKey( 'blocktoken', $tokens, 'Sanity check' ); @@ -57,7 +61,7 @@ class ApiBlockTest extends ApiTestCase { $ret = $this->doApiRequest( array_merge( $params, $extraParams ), null, false, $blocker ); - $block = Block::newFromTarget( $this->mUser->getName() ); + $block = DatabaseBlock::newFromTarget( $this->mUser->getName() ); $this->assertTrue( !is_null( $block ), 'Block is valid' ); @@ -89,7 +93,7 @@ class ApiBlockTest extends ApiTestCase { 'You cannot block or unblock other users because you are yourself blocked.' ); $blocked = $this->getMutableTestUser( [ 'sysop' ] )->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $blocked->getName(), 'by' => self::$users['sysop']->getUser()->getId(), 'reason' => 'Capriciousness', @@ -146,6 +150,8 @@ class ApiBlockTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); + $this->resetServices(); + $this->doBlock( [ 'tags' => 'custom tag' ] ); } @@ -156,6 +162,7 @@ class ApiBlockTest extends ApiTestCase { $this->mergeMwGlobalArrayValue( 'wgGroupPermissions', [ 'sysop' => $newPermissions ] ); + $this->resetServices(); $res = $this->doBlock( [ 'hidename' => '' ] ); $dbw = wfGetDB( DB_MASTER ); @@ -175,6 +182,12 @@ class ApiBlockTest extends ApiTestCase { } public function testBlockWithEmailBlock() { + $this->setMwGlobals( [ + 'wgEnableEmail' => true, + 'wgEnableUserEmail' => true, + 'wgSysopEmailBans' => true, + ] ); + $res = $this->doBlock( [ 'noemail' => '' ] ); $dbw = wfGetDB( DB_MASTER ); @@ -187,12 +200,20 @@ class ApiBlockTest extends ApiTestCase { } public function testBlockWithProhibitedEmailBlock() { + $this->setMwGlobals( [ + 'wgEnableEmail' => true, + 'wgEnableUserEmail' => true, + 'wgSysopEmailBans' => true, + ] ); + $this->setExpectedException( ApiUsageException::class, "You don't have permission to block users from sending email through the wiki." ); $this->setMwGlobals( 'wgRevokePermissions', [ 'sysop' => [ 'blockemail' => true ] ] ); + $this->resetServices(); + $this->doBlock( [ 'noemail' => '' ] ); } @@ -225,7 +246,7 @@ class ApiBlockTest extends ApiTestCase { $this->doBlock(); - $block = Block::newFromTarget( $this->mUser->getName() ); + $block = DatabaseBlock::newFromTarget( $this->mUser->getName() ); $this->assertTrue( $block->isSitewide() ); $this->assertCount( 0, $block->getRestrictions() ); @@ -246,7 +267,7 @@ class ApiBlockTest extends ApiTestCase { 'namespacerestrictions' => $namespace, ] ); - $block = Block::newFromTarget( $this->mUser->getName() ); + $block = DatabaseBlock::newFromTarget( $this->mUser->getName() ); $this->assertFalse( $block->isSitewide() ); $this->assertCount( 2, $block->getRestrictions() ); @@ -298,7 +319,7 @@ class ApiBlockTest extends ApiTestCase { * @expectedExceptionMessage Too many values supplied for parameter "pagerestrictions". The * limit is 10. */ - public function testBlockingToManyPageRestrictions() { + public function testBlockingTooManyPageRestrictions() { $this->setMwGlobals( [ 'wgEnablePartialBlocks' => true, ] ); @@ -319,4 +340,18 @@ class ApiBlockTest extends ApiTestCase { self::$users['sysop']->getUser() ); } + + public function testRangeBlock() { + $this->mUser = User::newFromName( '128.0.0.0/16', false ); + $this->doBlock(); + } + + /** + * @expectedException ApiUsageException + * @expectedExceptionMessage Range blocks larger than /16 are not allowed. + */ + public function testVeryLargeRangeBlock() { + $this->mUser = User::newFromName( '128.0.0.0/1', false ); + $this->doBlock(); + } } diff --git a/tests/phpunit/includes/api/ApiDeleteTest.php b/tests/phpunit/includes/api/ApiDeleteTest.php index c68954c077..cc5dadaa93 100644 --- a/tests/phpunit/includes/api/ApiDeleteTest.php +++ b/tests/phpunit/includes/api/ApiDeleteTest.php @@ -143,6 +143,7 @@ class ApiDeleteTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); + $this->resetServices(); $this->editPage( $name, 'Some text' ); diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php index aeb829dd62..5e5fea321f 100644 --- a/tests/phpunit/includes/api/ApiEditPageTest.php +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -1,5 +1,7 @@ tablesUsed, [ 'change_tag', 'change_tag_def', 'logging' ] ); + $this->resetServices(); } public function testEdit() { @@ -408,10 +411,10 @@ class ApiEditPageTest extends ApiTestCase { $count++; /* - * T43990: if the target page has a newer revision than the redirect, then editing the - * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously - * caused an edit conflict to be detected. - */ + * T43990: if the target page has a newer revision than the redirect, then editing the + * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously + * caused an edit conflict to be detected. + */ // assume NS_HELP defaults to wikitext $name = "Help:ApiEditPageTest_testEditConflict_redirect_T43990_$count"; @@ -1365,69 +1368,21 @@ class ApiEditPageTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'applychangetags' => true ] ] ); - try { - $this->doApiRequestWithToken( [ - 'action' => 'edit', - 'title' => $name, - 'text' => 'Some text', - 'tags' => 'custom tag', - ] ); - } finally { - $this->assertFalse( Title::newFromText( $name )->exists() ); - } - } - - public function testEditAbortedByHook() { - $name = 'Help:' . ucfirst( __FUNCTION__ ); - - $this->setExpectedException( ApiUsageException::class, - 'The modification you tried to make was aborted by an extension.' ); - - $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . - 'hook-APIEditBeforeSave-closure)' ); - - $this->setTemporaryHook( 'APIEditBeforeSave', - function () { - return false; - } - ); + // Supply services with updated globals + $this->resetServices(); try { $this->doApiRequestWithToken( [ 'action' => 'edit', 'title' => $name, 'text' => 'Some text', + 'tags' => 'custom tag', ] ); } finally { $this->assertFalse( Title::newFromText( $name )->exists() ); } } - public function testEditAbortedByHookWithCustomOutput() { - $name = 'Help:' . ucfirst( __FUNCTION__ ); - - $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . - 'hook-APIEditBeforeSave-closure)' ); - - $this->setTemporaryHook( 'APIEditBeforeSave', - function ( $unused1, $unused2, &$r ) { - $r['msg'] = 'Some message'; - return false; - } ); - - $result = $this->doApiRequestWithToken( [ - 'action' => 'edit', - 'title' => $name, - 'text' => 'Some text', - ] ); - Wikimedia\restoreWarnings(); - - $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ], - $result[0]['edit'] ); - - $this->assertFalse( Title::newFromText( $name )->exists() ); - } - public function testEditAbortedByEditPageHookWithResult() { $name = 'Help:' . ucfirst( __FUNCTION__ ); @@ -1474,9 +1429,9 @@ class ApiEditPageTest extends ApiTestCase { public function testEditWhileBlocked() { $name = 'Help:' . ucfirst( __FUNCTION__ ); - $this->assertNull( Block::newFromTarget( '127.0.0.1' ), 'Sanity check' ); + $this->assertNull( DatabaseBlock::newFromTarget( '127.0.0.1' ), 'Sanity check' ); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => self::$users['sysop']->getUser()->getName(), 'by' => self::$users['sysop']->getUser()->getId(), 'reason' => 'Capriciousness', @@ -1495,7 +1450,7 @@ class ApiEditPageTest extends ApiTestCase { $this->fail( 'Expected exception not thrown' ); } catch ( ApiUsageException $ex ) { $this->assertSame( 'You have been blocked from editing.', $ex->getMessage() ); - $this->assertNotNull( Block::newFromTarget( '127.0.0.1' ), 'Autoblock spread' ); + $this->assertNotNull( DatabaseBlock::newFromTarget( '127.0.0.1' ), 'Autoblock spread' ); } finally { $block->delete(); self::$users['sysop']->getUser()->clearInstanceCache(); @@ -1543,6 +1498,8 @@ class ApiEditPageTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'upload' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', @@ -1558,6 +1515,8 @@ class ApiEditPageTest extends ApiTestCase { 'The content you supplied exceeds the article size limit of 1 kilobyte.' ); $this->setMwGlobals( 'wgMaxArticleSize', 1 ); + // Supply services with updated globals + $this->resetServices(); $text = str_repeat( '!', 1025 ); @@ -1575,6 +1534,8 @@ class ApiEditPageTest extends ApiTestCase { 'The action you have requested is limited to users in the group: ' ); $this->setMwGlobals( 'wgRevokePermissions', [ '*' => [ 'edit' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', @@ -1591,6 +1552,8 @@ class ApiEditPageTest extends ApiTestCase { $this->setMwGlobals( 'wgRevokePermissions', [ 'user' => [ 'editcontentmodel' => true ] ] ); + // Supply services with updated globals + $this->resetServices(); $this->doApiRequestWithToken( [ 'action' => 'edit', diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index 9cb84e23bc..580efcd933 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -141,6 +141,7 @@ class ApiMainTest extends ApiTestCase { public function testSetCacheModeUnrecognized() { $api = new ApiMain(); $api->setCacheMode( 'unrecognized' ); + $this->resetServices(); $this->assertSame( 'private', TestingAccessWrapper::newFromObject( $api )->mCacheMode, @@ -150,7 +151,6 @@ class ApiMainTest extends ApiTestCase { public function testSetCacheModePrivateWiki() { $this->setGroupPermissions( '*', 'read', false ); - $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() ); $wrappedApi->setCacheMode( 'public' ); $this->assertSame( 'private', $wrappedApi->mCacheMode ); @@ -401,7 +401,7 @@ class ApiMainTest extends ApiTestCase { } else { $user = new User(); } - $user->mRights = $rights; + $this->overrideUserPermissions( $user, $rights ); try { $this->doApiRequest( [ 'action' => 'query', @@ -490,7 +490,7 @@ class ApiMainTest extends ApiTestCase { * @param int $status Expected response status * @param array $options Array of options: * post => true Request is a POST - * cdn => true CDN is enabled ($wgUseSquid) + * cdn => true CDN is enabled ($wgUseCdn) */ public function testCheckConditionalRequestHeaders( $headers, $conditions, $status, $options = [] @@ -508,7 +508,7 @@ class ApiMainTest extends ApiTestCase { $priv->mInternalMode = false; if ( !empty( $options['cdn'] ) ) { - $this->setMwGlobals( 'wgUseSquid', true ); + $this->setMwGlobals( 'wgUseCdn', true ); } // Can't do this in TestSetup.php because Setup.php will override it @@ -531,7 +531,7 @@ class ApiMainTest extends ApiTestCase { } public static function provideCheckConditionalRequestHeaders() { - global $wgSquidMaxage; + global $wgCdnMaxAge; $now = time(); return [ @@ -614,15 +614,15 @@ class ApiMainTest extends ApiTestCase { [ [ 'If-Modified-Since' => 'a potato' ], [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ], - // Anything before $wgSquidMaxage seconds ago should be considered + // Anything before $wgCdnMaxAge seconds ago should be considered // expired. 'If-Modified-Since with CDN post-expiry' => - [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage * 2 ) ], - [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ], + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge * 2 ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ], 200, [ 'cdn' => true ] ], 'If-Modified-Since with CDN pre-expiry' => - [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage / 2 ) ], - [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ], + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge / 2 ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ], 304, [ 'cdn' => true ] ], ]; } diff --git a/tests/phpunit/includes/api/ApiMoveTest.php b/tests/phpunit/includes/api/ApiMoveTest.php index d437a525a0..c98308cc88 100644 --- a/tests/phpunit/includes/api/ApiMoveTest.php +++ b/tests/phpunit/includes/api/ApiMoveTest.php @@ -1,5 +1,7 @@ assertNull( Block::newFromTarget( '127.0.0.1' ), 'Sanity check' ); + $this->assertNull( DatabaseBlock::newFromTarget( '127.0.0.1' ), 'Sanity check' ); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => self::$users['sysop']->getUser()->getName(), 'by' => self::$users['sysop']->getUser()->getId(), 'reason' => 'Capriciousness', @@ -156,7 +158,7 @@ class ApiMoveTest extends ApiTestCase { $this->fail( 'Expected exception not thrown' ); } catch ( ApiUsageException $ex ) { $this->assertSame( 'You have been blocked from editing.', $ex->getMessage() ); - $this->assertNotNull( Block::newFromTarget( '127.0.0.1' ), 'Autoblock spread' ); + $this->assertNotNull( DatabaseBlock::newFromTarget( '127.0.0.1' ), 'Autoblock spread' ); } finally { $block->delete(); self::$users['sysop']->getUser()->clearInstanceCache(); @@ -292,6 +294,7 @@ class ApiMoveTest extends ApiTestCase { $name = ucfirst( __FUNCTION__ ); $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] ); + $this->resetServices(); $pages = [ $name, "$name/1", "$name/2", "Talk:$name", "Talk:$name/1", "Talk:$name/3" ]; $ids = []; @@ -377,7 +380,6 @@ class ApiMoveTest extends ApiTestCase { $name = ucfirst( __FUNCTION__ ); $this->setGroupPermissions( 'sysop', 'suppressredirect', false ); - $id = $this->createPage( $name ); $res = $this->doApiRequestWithToken( [ diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php index 0011d7a2c8..a87160a1d8 100644 --- a/tests/phpunit/includes/api/ApiParseTest.php +++ b/tests/phpunit/includes/api/ApiParseTest.php @@ -121,7 +121,7 @@ class ApiParseTest extends ApiTestCase { $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] ); $this->tablesUsed[] = 'interwiki'; - $this->overrideMwServices(); + $this->resetServices(); } /** diff --git a/tests/phpunit/includes/api/ApiQueryBlocksTest.php b/tests/phpunit/includes/api/ApiQueryBlocksTest.php index 6e0084276f..e281679926 100644 --- a/tests/phpunit/includes/api/ApiQueryBlocksTest.php +++ b/tests/phpunit/includes/api/ApiQueryBlocksTest.php @@ -1,5 +1,6 @@ getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), @@ -57,7 +58,7 @@ class ApiQueryBlocksTest extends ApiTestCase { $badActor = $this->getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), @@ -87,7 +88,7 @@ class ApiQueryBlocksTest extends ApiTestCase { $badActor = $this->getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), diff --git a/tests/phpunit/includes/api/ApiQueryInfoTest.php b/tests/phpunit/includes/api/ApiQueryInfoTest.php index 3aaad486e9..5da618e563 100644 --- a/tests/phpunit/includes/api/ApiQueryInfoTest.php +++ b/tests/phpunit/includes/api/ApiQueryInfoTest.php @@ -1,5 +1,7 @@ getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), diff --git a/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php b/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php new file mode 100644 index 0000000000..6bbdd3bd53 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryLanguageinfoTest.php @@ -0,0 +1,176 @@ +setTemporaryHook( + 'LanguageGetTranslatedLanguageNames', + function ( array &$names, $code ) { + switch ( $code ) { + case 'en': + $names['sh'] = 'Serbo-Croatian'; + $names['qtp'] = 'a custom language code MediaWiki knows nothing about'; + break; + case 'pt': + $names['de'] = 'alemão'; + break; + } + } + ); + Language::clearCaches(); + } + + private function doQuery( array $params, $microtimeFunction = null ): array { + $params += [ + 'action' => 'query', + 'meta' => 'languageinfo', + 'uselang' => 'en', + ]; + + if ( $microtimeFunction !== null ) { + // hook into the module manager to override the factory function + // so we can call the constructor with the custom $microtimeFunction + $this->setTemporaryHook( + 'ApiQuery::moduleManager', + function ( ApiModuleManager $moduleManager ) use ( $microtimeFunction ) { + $moduleManager->addModule( + 'languageinfo', + 'meta', + ApiQueryLanguageinfo::class, + function ( $parent, $name ) use ( $microtimeFunction ) { + return new ApiQueryLanguageinfo( + $parent, + $name, + $microtimeFunction + ); + } + ); + } + ); + } + + $res = $this->doApiRequest( $params ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + + return [ $res[0]['query']['languageinfo'], $res[0]['continue'] ?? null ]; + } + + public function testAllPropsForSingleLanguage() { + list( $response, $continue ) = $this->doQuery( [ + 'liprop' => 'code|bcp47|dir|autonym|name|fallbacks|variants', + 'licode' => 'sh', + ] ); + + $this->assertArrayEquals( [ + 'sh' => [ + 'code' => 'sh', + 'bcp47' => 'sh', + 'autonym' => 'srpskohrvatski / српскохрватски', + 'name' => 'Serbo-Croatian', + 'fallbacks' => [ 'bs', 'sr-el', 'hr' ], + 'dir' => 'ltr', + 'variants' => [ 'sh' ], + ], + ], $response ); + } + + public function testAllPropsForSingleCustomLanguage() { + list( $response, $continue ) = $this->doQuery( [ + 'liprop' => 'code|bcp47|dir|autonym|name|fallbacks|variants', + 'licode' => 'qtp', // reserved for local use by ISO 639; registered in setUp() + ] ); + + $this->assertArrayEquals( [ + 'qtp' => [ + 'code' => 'qtp', + 'bcp47' => 'qtp', + 'autonym' => '', + 'name' => 'a custom language code MediaWiki knows nothing about', + 'fallbacks' => [], + 'dir' => 'ltr', + 'variants' => [ 'qtp' ], + ], + ], $response ); + } + + public function testNameInOtherLanguageForSingleLanguage() { + list( $response, $continue ) = $this->doQuery( [ + 'liprop' => 'name', + 'licode' => 'de', + 'uselang' => 'pt', + ] ); + + $this->assertArrayEquals( [ 'de' => [ 'name' => 'alemão' ] ], $response ); + } + + public function testContinuationNecessary() { + $time = 0; + $microtimeFunction = function () use ( &$time ) { + return $time += 0.75; + }; + + list( $response, $continue ) = $this->doQuery( [], $microtimeFunction ); + + $this->assertCount( 2, $response ); + $this->assertArrayHasKey( 'licontinue', $continue ); + } + + public function testContinuationNotNecessary() { + $time = 0; + $microtimeFunction = function () use ( &$time ) { + return $time += 1.5; + }; + + list( $response, $continue ) = $this->doQuery( [ + 'licode' => 'de', + ], $microtimeFunction ); + + $this->assertNull( $continue ); + } + + public function testContinuationInAlphabeticalOrderNotParameterOrder() { + $time = 0; + $microtimeFunction = function () use ( &$time ) { + return $time += 0.75; + }; + $params = [ 'licode' => 'en|ru|zh|de|yue' ]; + + list( $response, $continue ) = $this->doQuery( $params, $microtimeFunction ); + + $this->assertCount( 2, $response ); + $this->assertArrayHasKey( 'licontinue', $continue ); + $this->assertSame( [ 'de', 'en' ], array_keys( $response ) ); + + $time = 0; + $params = $continue + $params; + list( $response, $continue ) = $this->doQuery( $params, $microtimeFunction ); + + $this->assertCount( 2, $response ); + $this->assertArrayHasKey( 'licontinue', $continue ); + $this->assertSame( [ 'ru', 'yue' ], array_keys( $response ) ); + + $time = 0; + $params = $continue + $params; + list( $response, $continue ) = $this->doQuery( $params, $microtimeFunction ); + + $this->assertCount( 1, $response ); + $this->assertNull( $continue ); + $this->assertSame( [ 'zh' ], array_keys( $response ) ); + } + + public function testResponseHasModulePathEvenIfEmpty() { + list( $response, $continue ) = $this->doQuery( [ 'licode' => '' ] ); + $this->assertEmpty( $response ); + // the real test is that $res[0]['query']['languageinfo'] in doQuery() didn’t fail + } + +} diff --git a/tests/phpunit/includes/api/ApiQueryUserInfoTest.php b/tests/phpunit/includes/api/ApiQueryUserInfoTest.php new file mode 100644 index 0000000000..556818e85e --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryUserInfoTest.php @@ -0,0 +1,53 @@ +hideDeprecated( 'ApiQueryUserInfo::getBlockInfo' ); + + $apiQueryUserInfo = new ApiQueryUserInfo( + new ApiQuery( new ApiMain( $this->apiContext ), 'userinfo' ), + 'userinfo' + ); + + $block = new DatabaseBlock(); + $info = $apiQueryUserInfo->getBlockInfo( $block ); + $subset = [ + 'blockid' => null, + 'blockedby' => '', + 'blockedbyid' => 0, + 'blockreason' => '', + 'blockexpiry' => 'infinite', + 'blockpartial' => false, + ]; + $this->assertArraySubset( $subset, $info ); + } + + public function testGetBlockInfoPartial() { + $this->hideDeprecated( 'ApiQueryUserInfo::getBlockInfo' ); + + $apiQueryUserInfo = new ApiQueryUserInfo( + new ApiQuery( new ApiMain( $this->apiContext ), 'userinfo' ), + 'userinfo' + ); + + $block = new DatabaseBlock( [ + 'sitewide' => false, + ] ); + $info = $apiQueryUserInfo->getBlockInfo( $block ); + $subset = [ + 'blockid' => null, + 'blockedby' => '', + 'blockedbyid' => 0, + 'blockreason' => '', + 'blockexpiry' => 'infinite', + 'blockpartial' => true, + ]; + $this->assertArraySubset( $subset, $info ); + } +} diff --git a/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php index dacd48f623..187e9e083b 100644 --- a/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php @@ -1,4 +1,5 @@ addGroup( 'bot' ); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertFalse( $this->doCheckCache( $user ), "We assume bots don't have cache entries" @@ -315,6 +316,7 @@ class ApiStashEditTest extends ApiTestCase { // But other groups are okay $user->removeGroup( 'bot' ); $user->addGroup( 'sysop' ); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) ); } @@ -347,8 +349,6 @@ class ApiStashEditTest extends ApiTestCase { $cache = $editStash->cache; $editInfo = $cache->get( $key ); - $outputKey = $cache->makeKey( 'stashed-edit-output', $editInfo->outputID ); - $editInfo->output = $cache->get( $outputKey ); $editInfo->output->setCacheTime( wfTimestamp( TS_MW, wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() ) - $howOld - 1 ) ); diff --git a/tests/phpunit/includes/api/ApiUnblockTest.php b/tests/phpunit/includes/api/ApiUnblockTest.php index ea39da70b2..a1754faa58 100644 --- a/tests/phpunit/includes/api/ApiUnblockTest.php +++ b/tests/phpunit/includes/api/ApiUnblockTest.php @@ -1,5 +1,7 @@ blockee = $this->getMutableTestUser()->getUser(); // Initialize a blocked user (used by most tests, although not all) - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $this->blockee->getName(), 'by' => $this->blocker->getId(), ] ); $result = $block->insert(); $this->assertNotFalse( $result, 'Could not insert block' ); - $blockFromDB = Block::newFromID( $result['id'] ); + $blockFromDB = DatabaseBlock::newFromID( $result['id'] ); $this->assertTrue( !is_null( $blockFromDB ), 'Could not retrieve block' ); } private function getBlockFromParams( array $params ) { if ( array_key_exists( 'user', $params ) ) { - return Block::newFromTarget( $params['user'] ); + return DatabaseBlock::newFromTarget( $params['user'] ); } if ( array_key_exists( 'userid', $params ) ) { - return Block::newFromTarget( User::newFromId( $params['userid'] ) ); + return DatabaseBlock::newFromTarget( User::newFromId( $params['userid'] ) ); } - return Block::newFromId( $params['id'] ); + return DatabaseBlock::newFromId( $params['id'] ); } /** @@ -64,7 +66,7 @@ class ApiUnblockTest extends ApiTestCase { // We only check later on whether the block existed to begin with, because maybe the caller // expects doApiRequestWithToken to throw, in which case the block might not be expected to // exist to begin with. - $this->assertInstanceOf( Block::class, $originalBlock, 'Block should initially exist' ); + $this->assertInstanceOf( DatabaseBlock::class, $originalBlock, 'Block should initially exist' ); $this->assertNull( $this->getBlockFromParams( $params ), 'Block should have been removed' ); } @@ -94,7 +96,7 @@ class ApiUnblockTest extends ApiTestCase { public function testUnblockWhenBlocked() { $this->setExpectedApiException( 'ipbblocked' ); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $this->blocker->getName(), 'by' => $this->getTestUser( 'sysop' )->getUser()->getId(), ] ); @@ -104,7 +106,7 @@ class ApiUnblockTest extends ApiTestCase { } public function testUnblockSelfWhenBlocked() { - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $this->blocker->getName(), 'by' => $this->getTestUser( 'sysop' )->getUser()->getId(), ] ); diff --git a/tests/phpunit/includes/api/ApiUserrightsTest.php b/tests/phpunit/includes/api/ApiUserrightsTest.php index 5889f8265a..0d7ad0c502 100644 --- a/tests/phpunit/includes/api/ApiUserrightsTest.php +++ b/tests/phpunit/includes/api/ApiUserrightsTest.php @@ -1,5 +1,8 @@ mergeMwGlobalArrayValue( 'wgRemoveGroups', [ 'bureaucrat' => $remove ] ); } + + $this->resetServices(); } /** @@ -73,6 +78,7 @@ class ApiUserrightsTest extends ApiTestCase { $res = $this->doApiRequestWithToken( $params ); $user->clearInstanceCache(); + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $this->assertSame( $expectedGroups, $user->getGroups() ); $this->assertArrayNotHasKey( 'warnings', $res[0] ); @@ -128,7 +134,7 @@ class ApiUserrightsTest extends ApiTestCase { public function testBlockedWithUserrights() { global $wgUser; - $block = new Block( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] ); + $block = new DatabaseBlock( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] ); $block->insert(); try { @@ -144,7 +150,7 @@ class ApiUserrightsTest extends ApiTestCase { $this->setPermissions( true, true ); - $block = new Block( [ 'address' => $user, 'by' => $user->getId() ] ); + $block = new DatabaseBlock( [ 'address' => $user, 'by' => $user->getId() ] ); $block->insert(); try { @@ -215,6 +221,7 @@ class ApiUserrightsTest extends ApiTestCase { ChangeTags::defineTag( 'custom tag' ); $this->setGroupPermissions( 'user', 'applychangetags', false ); + $this->resetServices(); $this->doFailedRightsChange( 'You do not have permission to apply change tags along with your changes.', diff --git a/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php index de8d8156f3..20bd8557d0 100644 --- a/tests/phpunit/includes/api/query/ApiQueryTest.php +++ b/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -148,4 +148,28 @@ class ApiQueryTest extends ApiTestCase { ); } } + + public function testShouldNotExportPagesThatUserCanNotRead() { + $title = Title::makeTitle( NS_MAIN, 'Test article' ); + $this->insertPage( $title ); + + $this->setTemporaryHook( 'getUserPermissionsErrors', + function ( Title $page, &$user, $action, &$result ) use ( $title ) { + if ( $page->equals( $title ) && $action === 'read' ) { + $result = false; + return false; + } + } ); + + $data = $this->doApiRequest( [ + 'action' => 'query', + 'titles' => $title->getPrefixedText(), + 'export' => 1, + ] ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'export', $data[0]['query'] ); + // This response field contains an XML document even if no pages were exported + $this->assertNotContains( $title->getPrefixedText(), $data[0]['query']['export'] ); + } } diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php index 7869bbd2db..71a77b62ab 100644 --- a/tests/phpunit/includes/api/query/ApiQueryTestBase.php +++ b/tests/phpunit/includes/api/query/ApiQueryTestBase.php @@ -86,7 +86,7 @@ STR; /** * Checks that the request's result matches the expected results. * Assumes no rawcontinue and a complete batch. - * @param array $values Array is a two element array( request, expected_results ) + * @param array $values Array is a two element [ request, expected_results ] * @param array|null $session * @param bool $appendModule * @param User|null $user diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php index 5cf93c9e2b..fc6f688091 100644 --- a/tests/phpunit/includes/auth/AuthManagerTest.php +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -3,6 +3,7 @@ namespace MediaWiki\Auth; use Config; +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Session\SessionInfo; use MediaWiki\Session\UserInfo; use Psr\Log\LoggerInterface; @@ -1430,7 +1431,7 @@ class AuthManagerTest extends \MediaWikiTestCase { \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); $user->saveSettings(); } - $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + $oldBlock = DatabaseBlock::newFromTarget( 'UTBlockee' ); if ( $oldBlock ) { // An old block will prevent our new one from saving. $oldBlock->delete(); @@ -1443,8 +1444,9 @@ class AuthManagerTest extends \MediaWikiTestCase { 'expiry' => time() + 100500, 'createAccount' => true, ]; - $block = new \Block( $blockOptions ); + $block = new DatabaseBlock( $blockOptions ); $block->insert(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( $user ); $this->assertFalse( $status->isOK() ); $this->assertTrue( $status->hasMessage( 'cantcreateaccount-text' ) ); @@ -1456,7 +1458,7 @@ class AuthManagerTest extends \MediaWikiTestCase { 'expiry' => time() + 100500, 'createAccount' => true, ]; - $block = new \Block( $blockOptions ); + $block = new DatabaseBlock( $blockOptions ); $block->insert(); $scopeVariable = new ScopedCallback( [ $block, 'delete' ] ); $status = $this->manager->checkAccountCreatePermissions( new \User ); @@ -1471,12 +1473,12 @@ class AuthManagerTest extends \MediaWikiTestCase { ], 'wgProxyWhitelist' => [], ] ); - $this->overrideMwServices(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( new \User ); $this->assertFalse( $status->isOK() ); $this->assertTrue( $status->hasMessage( 'sorbs_create_account_reason' ) ); $this->setMwGlobals( 'wgProxyWhitelist', [ '127.0.0.1' ] ); - $this->overrideMwServices(); + $this->resetServices(); $status = $this->manager->checkAccountCreatePermissions( new \User ); $this->assertTrue( $status->isGood() ); } @@ -2364,6 +2366,8 @@ class AuthManagerTest extends \MediaWikiTestCase { $this->mergeMwGlobalArrayValue( 'wgObjectCaches', [ __METHOD__ => [ 'class' => 'HashBagOStuff' ] ] ); $this->setMwGlobals( [ 'wgMainCacheType' => __METHOD__ ] ); + // Supply services with updated globals + $this->resetServices(); // Set up lots of mocks... $mocks = []; diff --git a/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/includes/auth/AuthenticationResponseTest.php deleted file mode 100644 index c79682275f..0000000000 --- a/tests/phpunit/includes/auth/AuthenticationResponseTest.php +++ /dev/null @@ -1,112 +0,0 @@ -messageType = 'warning'; - foreach ( $expect as $field => $value ) { - $res->$field = $value; - } - $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); - $this->assertEquals( $res, $ret ); - } else { - try { - call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); - $this->fail( 'Expected exception not thrown' ); - } catch ( \Exception $ex ) { - $this->assertEquals( $expect, $ex ); - } - } - } - - public function provideConstructors() { - $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); - $msg = new \Message( 'mainpage' ); - - return [ - [ 'newPass', [], [ - 'status' => AuthenticationResponse::PASS, - ] ], - [ 'newPass', [ 'name' ], [ - 'status' => AuthenticationResponse::PASS, - 'username' => 'name', - ] ], - [ 'newPass', [ 'name', null ], [ - 'status' => AuthenticationResponse::PASS, - 'username' => 'name', - ] ], - - [ 'newFail', [ $msg ], [ - 'status' => AuthenticationResponse::FAIL, - 'message' => $msg, - 'messageType' => 'error', - ] ], - - [ 'newRestart', [ $msg ], [ - 'status' => AuthenticationResponse::RESTART, - 'message' => $msg, - ] ], - - [ 'newAbstain', [], [ - 'status' => AuthenticationResponse::ABSTAIN, - ] ], - - [ 'newUI', [ [ $req ], $msg ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'warning', - ] ], - - [ 'newUI', [ [ $req ], $msg, 'warning' ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'warning', - ] ], - - [ 'newUI', [ [ $req ], $msg, 'error' ], [ - 'status' => AuthenticationResponse::UI, - 'neededRequests' => [ $req ], - 'message' => $msg, - 'messageType' => 'error', - ] ], - [ 'newUI', [ [], $msg ], - new \InvalidArgumentException( '$reqs may not be empty' ) - ], - - [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ - 'status' => AuthenticationResponse::REDIRECT, - 'neededRequests' => [ $req ], - 'redirectTarget' => 'http://example.org/redir', - ] ], - [ - 'newRedirect', - [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], - [ - 'status' => AuthenticationResponse::REDIRECT, - 'neededRequests' => [ $req ], - 'redirectTarget' => 'http://example.org/redir', - 'redirectApiData' => [ 'foo' => 'bar' ], - ] - ], - [ 'newRedirect', [ [], 'http://example.org/redir' ], - new \InvalidArgumentException( '$reqs may not be empty' ) - ], - ]; - } - -} diff --git a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php index 14d7f09b96..c38003660a 100644 --- a/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php +++ b/tests/phpunit/includes/auth/CheckBlocksSecondaryAuthenticationProviderTest.php @@ -2,6 +2,7 @@ namespace MediaWiki\Auth; +use MediaWiki\Block\DatabaseBlock; use Wikimedia\TestingAccessWrapper; /** @@ -68,7 +69,7 @@ class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase \TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); $user->saveSettings(); } - $oldBlock = \Block::newFromTarget( 'UTBlockee' ); + $oldBlock = DatabaseBlock::newFromTarget( 'UTBlockee' ); if ( $oldBlock ) { // An old block will prevent our new one from saving. $oldBlock->delete(); @@ -81,7 +82,7 @@ class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase 'expiry' => time() + 100500, 'createAccount' => true, ]; - $block = new \Block( $blockOptions ); + $block = new DatabaseBlock( $blockOptions ); $block->insert(); return $user; } @@ -154,7 +155,7 @@ class CheckBlocksSecondaryAuthenticationProviderTest extends \MediaWikiTestCase 'expiry' => time() + 100500, 'createAccount' => true, ]; - $block = new \Block( $blockOptions ); + $block = new DatabaseBlock( $blockOptions ); $block->insert(); $scopeVariable = new \Wikimedia\ScopedCallback( [ $block, 'delete' ] ); diff --git a/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php index 1da4ee0ade..80cfbe699e 100644 --- a/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php +++ b/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php @@ -191,7 +191,8 @@ class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestC * @param array $expected */ public function testGetAuthenticationRequests( $action, $options, $expected ) { - $actual = $this->getProvider()->getAuthenticationRequests( $action, $options ); + $actual = $this->getProvider( [ 'emailEnabled' => true ] ) + ->getAuthenticationRequests( $action, $options ); foreach ( $actual as $req ) { if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) { $req->password = 'random'; @@ -521,11 +522,15 @@ class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestC $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); $this->assertEquals( \StatusValue::newFatal( 'passwordreset-emaildisabled' ), $status ); - $provider = $this->getProvider( [ 'passwordReminderResendTime' => 10 ] ); + $provider = $this->getProvider( [ + 'emailEnabled' => true, 'passwordReminderResendTime' => 10 + ] ); $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); $this->assertEquals( \StatusValue::newFatal( 'throttled-mailpassword', 10 ), $status ); - $provider = $this->getProvider( [ 'passwordReminderResendTime' => 3 ] ); + $provider = $this->getProvider( [ + 'emailEnabled' => true, 'passwordReminderResendTime' => 3 + ] ); $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); @@ -534,7 +539,9 @@ class TemporaryPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestC [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ], [ 'user_id' => $user->getId() ] ); - $provider = $this->getProvider( [ 'passwordReminderResendTime' => 0 ] ); + $provider = $this->getProvider( [ + 'emailEnabled' => true, 'passwordReminderResendTime' => 0 + ] ); $status = $provider->providerAllowsAuthenticationDataChange( $req, true ); $this->assertFalse( $status->hasMessage( 'throttled-mailpassword' ) ); diff --git a/tests/phpunit/includes/block/BlockManagerTest.php b/tests/phpunit/includes/block/BlockManagerTest.php index 414566503e..892add9786 100644 --- a/tests/phpunit/includes/block/BlockManagerTest.php +++ b/tests/phpunit/includes/block/BlockManagerTest.php @@ -1,6 +1,8 @@ user = $this->getTestUser()->getUser(); $this->sysopId = $this->getTestSysop()->getUser()->getId(); - } - - private function getBlockManager( $overrideConfig ) { - $blockManagerConfig = array_merge( [ + $this->blockManagerConfig = [ 'wgApplyIpBlocksToXff' => true, 'wgCookieSetOnAutoblock' => true, 'wgCookieSetOnIpBlock' => true, @@ -31,8 +30,13 @@ class BlockManagerTest extends MediaWikiTestCase { 'wgEnableDnsBlacklist' => true, 'wgProxyList' => [], 'wgProxyWhitelist' => [], + 'wgSecretKey' => false, 'wgSoftBlockRanges' => [], - ], $overrideConfig ); + ]; + } + + private function getBlockManager( $overrideConfig ) { + $blockManagerConfig = array_merge( $this->blockManagerConfig, $overrideConfig ); return new BlockManager( $this->user, $this->user->getRequest(), @@ -50,7 +54,7 @@ class BlockManagerTest extends MediaWikiTestCase { 'wgCookieSetOnIpBlock' => true, ] ); - $block = new Block( array_merge( [ + $block = new DatabaseBlock( array_merge( [ 'address' => $options[ 'target' ] ?: $this->user, 'by' => $this->sysopId, ], $options[ 'blockOptions' ] ) ); @@ -174,53 +178,207 @@ class BlockManagerTest extends MediaWikiTestCase { * @covers ::inDnsBlacklist */ public function testIsDnsBlacklisted( $options, $expected ) { - $blockManager = $this->getBlockManager( [ + $blockManagerConfig = array_merge( $this->blockManagerConfig, [ 'wgEnableDnsBlacklist' => true, - 'wgDnsBlacklistUrls' => $options[ 'inBlacklist' ] ? [ 'local.wmftest.net' ] : [], - 'wgProxyWhitelist' => $options[ 'inWhitelist' ] ? [ '127.0.0.1' ] : [], + 'wgDnsBlacklistUrls' => $options['blacklist'], + 'wgProxyWhitelist' => $options['whitelist'], ] ); - $ip = '127.0.0.1'; + $blockManager = $this->getMockBuilder( BlockManager::class ) + ->setConstructorArgs( + array_merge( [ + $this->user, + $this->user->getRequest(), + ], $blockManagerConfig ) ) + ->setMethods( [ 'checkHost' ] ) + ->getMock(); + + $blockManager->expects( $this->any() ) + ->method( 'checkHost' ) + ->will( $this->returnValueMap( [ [ + $options['dnsblQuery'], + $options['dnsblResponse'], + ] ] ) ); + $this->assertSame( $expected, - $blockManager->isDnsBlacklisted( $ip, $options[ 'check' ] ) + $blockManager->isDnsBlacklisted( $options['ip'], $options['checkWhitelist'] ) ); } public static function provideIsDnsBlacklisted() { + $dnsblFound = [ '127.0.0.2' ]; + $dnsblNotFound = false; return [ 'IP is blacklisted' => [ [ - 'inBlacklist' => true, - 'inWhitelist' => false, - 'check' => false, + 'blacklist' => [ 'dnsbl.test' ], + 'ip' => '127.0.0.1', + 'dnsblQuery' => '1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [], + 'checkWhitelist' => false, + ], + true, + ], + 'IP is blacklisted; blacklist has key' => [ + [ + 'blacklist' => [ [ 'dnsbl.test', 'key' ] ], + 'ip' => '127.0.0.1', + 'dnsblQuery' => 'key.1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [], + 'checkWhitelist' => false, + ], + true, + ], + 'IP is blacklisted; blacklist is array' => [ + [ + 'blacklist' => [ [ 'dnsbl.test' ] ], + 'ip' => '127.0.0.1', + 'dnsblQuery' => '1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [], + 'checkWhitelist' => false, ], true, ], 'IP is not blacklisted' => [ [ - 'inBlacklist' => false, - 'inWhitelist' => false, - 'check' => false, + 'blacklist' => [ 'dnsbl.test' ], + 'ip' => '1.2.3.4', + 'dnsblQuery' => '4.3.2.1.dnsbl.test', + 'dnsblResponse' => $dnsblNotFound, + 'whitelist' => [], + 'checkWhitelist' => false, ], false, ], - 'IP is blacklisted and whitelisted; whitelist is checked' => [ + 'Blacklist is empty' => [ [ - 'inBlacklist' => true, - 'inWhitelist' => true, - 'check' => false, + 'blacklist' => [], + 'ip' => '127.0.0.1', + 'dnsblQuery' => '1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [], + 'checkWhitelist' => false, ], - true, + false, ], 'IP is blacklisted and whitelisted; whitelist is not checked' => [ [ - 'inBlacklist' => true, - 'inWhitelist' => true, - 'check' => true, + 'blacklist' => [ 'dnsbl.test' ], + 'ip' => '127.0.0.1', + 'dnsblQuery' => '1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [ '127.0.0.1' ], + 'checkWhitelist' => false, + ], + true, + ], + 'IP is blacklisted and whitelisted; whitelist is checked' => [ + [ + 'blacklist' => [ 'dnsbl.test' ], + 'ip' => '127.0.0.1', + 'dnsblQuery' => '1.0.0.127.dnsbl.test', + 'dnsblResponse' => $dnsblFound, + 'whitelist' => [ '127.0.0.1' ], + 'checkWhitelist' => true, ], false, ], ]; } + + /** + * @covers ::getUniqueBlocks + */ + public function testGetUniqueBlocks() { + $blockId = 100; + + $class = new ReflectionClass( BlockManager::class ); + $method = $class->getMethod( 'getUniqueBlocks' ); + $method->setAccessible( true ); + + $blockManager = $this->getBlockManager( [] ); + + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getId' ] ) + ->getMock(); + $block->expects( $this->any() ) + ->method( 'getId' ) + ->willReturn( $blockId ); + + $autoblock = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getParentBlockId', 'getType' ] ) + ->getMock(); + $autoblock->expects( $this->any() ) + ->method( 'getParentBlockId' ) + ->willReturn( $blockId ); + $autoblock->expects( $this->any() ) + ->method( 'getType' ) + ->willReturn( DatabaseBlock::TYPE_AUTO ); + + $blocks = [ $block, $block, $autoblock, new SystemBlock() ]; + + $this->assertSame( 2, count( $method->invoke( $blockManager, $blocks ) ) ); + } + + /** + * @covers ::trackBlockWithCookie + * @dataProvider provideTrackBlockWithCookie + * @param bool $expectCookieSet + * @param bool $hasCookie + * @param bool $isBlocked + */ + public function testTrackBlockWithCookie( $expectCookieSet, $hasCookie, $isBlocked ) { + $blockID = 123; + $this->setMwGlobals( 'wgCookiePrefix', '' ); + + $request = new FauxRequest(); + if ( $hasCookie ) { + $request->setCookie( 'BlockID', 'the value does not matter' ); + } + + if ( $isBlocked ) { + $block = $this->getMockBuilder( DatabaseBlock::class ) + ->setMethods( [ 'getType', 'getId' ] ) + ->getMock(); + $block->method( 'getType' ) + ->willReturn( DatabaseBlock::TYPE_IP ); + $block->method( 'getId' ) + ->willReturn( $blockID ); + } else { + $block = null; + } + + $user = $this->getMockBuilder( User::class ) + ->setMethods( [ 'getBlock', 'getRequest' ] ) + ->getMock(); + $user->method( 'getBlock' ) + ->willReturn( $block ); + $user->method( 'getRequest' ) + ->willReturn( $request ); + /** @var User $user */ + + // Although the block cookie is set via DeferredUpdates, in command line mode updates are + // processed immediately + $blockManager = $this->getBlockManager( [] ); + $blockManager->trackBlockWithCookie( $user ); + + /** @var FauxResponse $response */ + $response = $request->response(); + $this->assertCount( $expectCookieSet ? 1 : 0, $response->getCookies() ); + $this->assertEquals( $expectCookieSet ? $blockID : null, $response->getCookie( 'BlockID' ) ); + } + + public function provideTrackBlockWithCookie() { + return [ + // $expectCookieSet, $hasCookie, $isBlocked + [ false, false, false ], + [ false, true, false ], + [ true, false, true ], + [ false, true, true ], + ]; + } } diff --git a/tests/phpunit/includes/block/BlockRestrictionStoreTest.php b/tests/phpunit/includes/block/BlockRestrictionStoreTest.php index 4eef457339..ebbfde27c0 100644 --- a/tests/phpunit/includes/block/BlockRestrictionStoreTest.php +++ b/tests/phpunit/includes/block/BlockRestrictionStoreTest.php @@ -3,6 +3,7 @@ namespace MediaWiki\Tests\Block; use MediaWiki\Block\BlockRestrictionStore; +use MediaWiki\Block\DatabaseBlock; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\Restriction\Restriction; @@ -583,7 +584,7 @@ class BlockRestrictionStoreTest extends \MediaWikiLangTestCase { $badActor = $this->getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), diff --git a/tests/phpunit/includes/block/CompositeBlockTest.php b/tests/phpunit/includes/block/CompositeBlockTest.php new file mode 100644 index 0000000000..5cd86b80b4 --- /dev/null +++ b/tests/phpunit/includes/block/CompositeBlockTest.php @@ -0,0 +1,254 @@ +getTestSysop()->getUser()->getId(); + + $userBlock = new Block( [ + 'address' => $this->getTestUser()->getUser(), + 'by' => $sysopId, + 'sitewide' => false, + ] ); + $ipBlock = new Block( [ + 'address' => '127.0.0.1', + 'by' => $sysopId, + 'sitewide' => false, + ] ); + + $userBlock->insert(); + $ipBlock->insert(); + + return [ + 'user' => $userBlock, + 'ip' => $ipBlock, + ]; + } + + private function deleteBlocks( $blocks ) { + foreach ( $blocks as $block ) { + $block->delete(); + } + } + + /** + * @covers ::__construct + * @dataProvider provideTestStrictestParametersApplied + */ + public function testStrictestParametersApplied( $blocks, $expected ) { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + 'wgBlockAllowsUTEdit' => true, + ] ); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $this->assertSame( $expected[ 'hideName' ], $block->getHideName() ); + $this->assertSame( $expected[ 'sitewide' ], $block->isSitewide() ); + $this->assertSame( $expected[ 'blockEmail' ], $block->isEmailBlocked() ); + $this->assertSame( $expected[ 'allowUsertalk' ], $block->isUsertalkEditAllowed() ); + } + + public static function provideTestStrictestParametersApplied() { + return [ + 'Sitewide block and partial block' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => true, + 'allowUsertalk' => true, + ] ), + new Block( [ + 'sitewide' => true, + 'blockEmail' => false, + 'allowUsertalk' => false, + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'Partial block and system block' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => true, + 'allowUsertalk' => false, + ] ), + new SystemBlock( [ + 'systemBlock' => 'proxy', + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'System block and user name hiding block' => [ + [ + new Block( [ + 'hideName' => true, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ] ), + new SystemBlock( [ + 'systemBlock' => 'proxy', + ] ), + ], + [ + 'hideName' => true, + 'sitewide' => true, + 'blockEmail' => true, + 'allowUsertalk' => false, + ], + ], + 'Two lenient partial blocks' => [ + [ + new Block( [ + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ] ), + new Block( [ + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ] ), + ], + [ + 'hideName' => false, + 'sitewide' => false, + 'blockEmail' => false, + 'allowUsertalk' => true, + ], + ], + ]; + } + + /** + * @covers ::appliesToTitle + */ + public function testBlockAppliesToTitle() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + + $blocks = $this->getPartialBlocks(); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $pageFoo = $this->getExistingTestPage( 'Foo' ); + $pageBar = $this->getExistingTestPage( 'User:Bar' ); + + $this->getBlockRestrictionStore()->insert( [ + new PageRestriction( $blocks[ 'user' ]->getId(), $pageFoo->getId() ), + new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ), + ] ); + + $this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) ); + $this->assertTrue( $block->appliesToTitle( $pageBar->getTitle() ) ); + + $this->deleteBlocks( $blocks ); + } + + /** + * @covers ::appliesToUsertalk + * @covers ::appliesToPage + * @covers ::appliesToNamespace + */ + public function testBlockAppliesToUsertalk() { + $this->setMwGlobals( [ + 'wgBlockAllowsUTEdit' => true, + 'wgBlockDisablesLogin' => false, + ] ); + + $blocks = $this->getPartialBlocks(); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $title = $blocks[ 'user' ]->getTarget()->getTalkPage(); + $page = $this->getExistingTestPage( 'User talk:' . $title->getText() ); + + $this->getBlockRestrictionStore()->insert( [ + new PageRestriction( $blocks[ 'user' ]->getId(), $page->getId() ), + new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ), + ] ); + + $this->assertTrue( $block->appliesToUsertalk( $blocks[ 'user' ]->getTarget()->getTalkPage() ) ); + + $this->deleteBlocks( $blocks ); + } + + /** + * @covers ::appliesToRight + * @dataProvider provideTestBlockAppliesToRight + */ + public function testBlockAppliesToRight( $blocks, $right, $expected ) { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + + $block = new CompositeBlock( [ + 'originalBlocks' => $blocks, + ] ); + + $this->assertSame( $block->appliesToRight( $right ), $expected ); + } + + public static function provideTestBlockAppliesToRight() { + return [ + 'Read is not blocked' => [ + [ + new Block(), + new Block(), + ], + 'read', + false, + ], + 'Email is blocked if blocked by any blocks' => [ + [ + new Block( [ + 'blockEmail' => true, + ] ), + new Block( [ + 'blockEmail' => false, + ] ), + ], + 'sendemail', + true, + ], + ]; + } + + /** + * Get an instance of BlockRestrictionStore + * + * @return BlockRestrictionStore + */ + protected function getBlockRestrictionStore() : BlockRestrictionStore { + return MediaWikiServices::getInstance()->getBlockRestrictionStore(); + } +} diff --git a/tests/phpunit/includes/block/DatabaseBlockTest.php b/tests/phpunit/includes/block/DatabaseBlockTest.php new file mode 100644 index 0000000000..0ef571db73 --- /dev/null +++ b/tests/phpunit/includes/block/DatabaseBlockTest.php @@ -0,0 +1,767 @@ +getMutableTestUser(); + $user = $testUser->getUser(); + $user->addToDatabase(); + TestUser::setPasswordForUser( $user, 'UTBlockeePassword' ); + $user->saveSettings(); + return $user; + } + + /** + * @param User $user + * + * @return DatabaseBlock + * @throws MWException + */ + private function addBlockForUser( User $user ) { + // Delete the last round's block if it's still there + $oldBlock = DatabaseBlock::newFromTarget( $user->getName() ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + $blockOptions = [ + 'address' => $user->getName(), + 'user' => $user->getId(), + 'by' => $this->getTestSysop()->getUser()->getId(), + 'reason' => 'Parce que', + 'expiry' => time() + 100500, + ]; + $block = new DatabaseBlock( $blockOptions ); + + $block->insert(); + // save up ID for use in assertion. Since ID is an autoincrement, + // its value might change depending on the order the tests are run. + // ApiBlockTest insert its own blocks! + if ( !$block->getId() ) { + throw new MWException( "Failed to insert block for BlockTest; old leftover block remaining?" ); + } + + $this->addXffBlocks(); + + return $block; + } + + /** + * @covers ::newFromTarget + */ + public function testINewFromTargetReturnsCorrectBlock() { + $user = $this->getUserForBlocking(); + $block = $this->addBlockForUser( $user ); + + $this->assertTrue( + $block->equals( DatabaseBlock::newFromTarget( $user->getName() ) ), + "newFromTarget() returns the same block as the one that was made" + ); + } + + /** + * @covers ::newFromID + */ + public function testINewFromIDReturnsCorrectBlock() { + $user = $this->getUserForBlocking(); + $block = $this->addBlockForUser( $user ); + + $this->assertTrue( + $block->equals( DatabaseBlock::newFromID( $block->getId() ) ), + "newFromID() returns the same block as the one that was made" + ); + } + + /** + * per T28425 + * @covers ::__construct + */ + public function testT28425BlockTimestampDefaultsToTime() { + $user = $this->getUserForBlocking(); + $block = $this->addBlockForUser( $user ); + $madeAt = wfTimestamp( TS_MW ); + + // delta to stop one-off errors when things happen to go over a second mark. + $delta = abs( $madeAt - $block->getTimestamp() ); + $this->assertLessThan( + 2, + $delta, + "If no timestamp is specified, the block is recorded as time()" + ); + } + + /** + * CheckUser since being changed to use DatabaseBlock::newFromTarget started failing + * because the new function didn't accept empty strings like DatabaseBlock::load() + * had. Regression T31116. + * + * @dataProvider provideT31116Data + * @covers ::newFromTarget + */ + public function testT31116NewFromTargetWithEmptyIp( $vagueTarget ) { + $user = $this->getUserForBlocking(); + $initialBlock = $this->addBlockForUser( $user ); + $block = DatabaseBlock::newFromTarget( $user->getName(), $vagueTarget ); + + $this->assertTrue( + $initialBlock->equals( $block ), + "newFromTarget() returns the same block as the one that was made when " + . "given empty vagueTarget param " . var_export( $vagueTarget, true ) + ); + } + + public static function provideT31116Data() { + return [ + [ null ], + [ '' ], + [ false ] + ]; + } + + /** + * @dataProvider provideNewFromTargetRangeBlocks + * @covers ::newFromTarget + */ + public function testNewFromTargetRangeBlocks( $targets, $ip, $expectedTarget ) { + $blocker = $this->getTestSysop()->getUser(); + + foreach ( $targets as $target ) { + $block = new DatabaseBlock(); + $block->setTarget( $target ); + $block->setBlocker( $blocker ); + $block->insert(); + } + + // Should find the block with the narrowest range + $blockTarget = DatabaseBlock::newFromTarget( $this->getTestUser()->getUser(), $ip )->getTarget(); + $this->assertSame( + $blockTarget instanceof User ? $blockTarget->getName() : $blockTarget, + $expectedTarget + ); + + foreach ( $targets as $target ) { + $block = DatabaseBlock::newFromTarget( $target ); + $block->delete(); + } + } + + function provideNewFromTargetRangeBlocks() { + return [ + 'Blocks to IPv4 ranges' => [ + [ '0.0.0.0/20', '0.0.0.0/30', '0.0.0.0/25' ], + '0.0.0.0', + '0.0.0.0/30' + ], + 'Blocks to IPv6 ranges' => [ + [ '0:0:0:0:0:0:0:0/20', '0:0:0:0:0:0:0:0/30', '0:0:0:0:0:0:0:0/25' ], + '0:0:0:0:0:0:0:0', + '0:0:0:0:0:0:0:0/30' + ], + 'Blocks to wide IPv4 range and IP' => [ + [ '0.0.0.0/16', '0.0.0.0' ], + '0.0.0.0', + '0.0.0.0' + ], + 'Blocks to narrow IPv4 range and IP' => [ + [ '0.0.0.0/31', '0.0.0.0' ], + '0.0.0.0', + '0.0.0.0' + ], + 'Blocks to wide IPv6 range and IP' => [ + [ '0:0:0:0:0:0:0:0/19', '0:0:0:0:0:0:0:0' ], + '0:0:0:0:0:0:0:0', + '0:0:0:0:0:0:0:0' + ], + 'Blocks to narrow IPv6 range and IP' => [ + [ '0:0:0:0:0:0:0:0/127', '0:0:0:0:0:0:0:0' ], + '0:0:0:0:0:0:0:0', + '0:0:0:0:0:0:0:0' + ], + 'Blocks to wide IPv6 range and IP, large numbers' => [ + [ '2000:DEAD:BEEF:A:0:0:0:0/19', '2000:DEAD:BEEF:A:0:0:0:0' ], + '2000:DEAD:BEEF:A:0:0:0:0', + '2000:DEAD:BEEF:A:0:0:0:0' + ], + 'Blocks to narrow IPv6 range and IP, large numbers' => [ + [ '2000:DEAD:BEEF:A:0:0:0:0/127', '2000:DEAD:BEEF:A:0:0:0:0' ], + '2000:DEAD:BEEF:A:0:0:0:0', + '2000:DEAD:BEEF:A:0:0:0:0' + ], + ]; + } + + /** + * @covers ::appliesToRight + */ + public function testBlockedUserCanNotCreateAccount() { + $username = 'BlockedUserToCreateAccountWith'; + $u = User::newFromName( $username ); + $u->addToDatabase(); + $userId = $u->getId(); + $this->assertNotEquals( 0, $userId, 'sanity' ); + TestUser::setPasswordForUser( $u, 'NotRandomPass' ); + unset( $u ); + + // Sanity check + $this->assertNull( + DatabaseBlock::newFromTarget( $username ), + "$username should not be blocked" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertFalse( + $u->isBlockedFromCreateAccount(), + "Our sandbox user should be able to create account before being blocked" + ); + + // Foreign perspective (blockee not on current wiki)... + $blockOptions = [ + 'address' => $username, + 'user' => $userId, + 'reason' => 'crosswiki block...', + 'timestamp' => wfTimestampNow(), + 'expiry' => $this->db->getInfinity(), + 'createAccount' => true, + 'enableAutoblock' => true, + 'hideName' => true, + 'blockEmail' => true, + 'byText' => 'm>MetaWikiUser', + ]; + $block = new DatabaseBlock( $blockOptions ); + $block->insert(); + + // Reload block from DB + $userBlock = DatabaseBlock::newFromTarget( $username ); + $this->assertTrue( + (bool)$block->appliesToRight( 'createaccount' ), + "Block object in DB should block right 'createaccount'" + ); + + $this->assertInstanceOf( + DatabaseBlock::class, + $userBlock, + "'$username' block block object should be existent" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertTrue( + (bool)$u->isBlockedFromCreateAccount(), + "Our sandbox user '$username' should NOT be able to create account" + ); + } + + /** + * @covers ::insert + */ + public function testCrappyCrossWikiBlocks() { + // Delete the last round's block if it's still there + $oldBlock = DatabaseBlock::newFromTarget( 'UserOnForeignWiki' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + // Local perspective (blockee on current wiki)... + $user = User::newFromName( 'UserOnForeignWiki' ); + $user->addToDatabase(); + $userId = $user->getId(); + $this->assertNotEquals( 0, $userId, 'sanity' ); + + // Foreign perspective (blockee not on current wiki)... + $blockOptions = [ + 'address' => 'UserOnForeignWiki', + 'user' => $user->getId(), + 'reason' => 'crosswiki block...', + 'timestamp' => wfTimestampNow(), + 'expiry' => $this->db->getInfinity(), + 'createAccount' => true, + 'enableAutoblock' => true, + 'hideName' => true, + 'blockEmail' => true, + 'byText' => 'Meta>MetaWikiUser', + ]; + $block = new DatabaseBlock( $blockOptions ); + + $res = $block->insert( $this->db ); + $this->assertTrue( (bool)$res['id'], 'Block succeeded' ); + + $user = null; // clear + + $block = DatabaseBlock::newFromID( $res['id'] ); + $this->assertEquals( + 'UserOnForeignWiki', + $block->getTarget()->getName(), + 'Correct blockee name' + ); + $this->assertEquals( $userId, $block->getTarget()->getId(), 'Correct blockee id' ); + $this->assertEquals( 'Meta>MetaWikiUser', $block->getBlocker()->getName(), + 'Correct blocker name' ); + $this->assertEquals( 'Meta>MetaWikiUser', $block->getByName(), 'Correct blocker name' ); + $this->assertEquals( 0, $block->getBy(), 'Correct blocker id' ); + } + + protected function addXffBlocks() { + static $inited = false; + + if ( $inited ) { + return; + } + + $inited = true; + + $blockList = [ + [ 'target' => '70.2.0.0/16', + 'type' => DatabaseBlock::TYPE_RANGE, + 'desc' => 'Range Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ], + [ 'target' => '2001:4860:4001::/48', + 'type' => DatabaseBlock::TYPE_RANGE, + 'desc' => 'Range6 Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ], + [ 'target' => '60.2.0.0/16', + 'type' => DatabaseBlock::TYPE_RANGE, + 'desc' => 'Range Softblock with AC Disabled', + 'ACDisable' => true, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ], + [ 'target' => '50.2.0.0/16', + 'type' => DatabaseBlock::TYPE_RANGE, + 'desc' => 'Range Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ], + [ 'target' => '50.1.1.1', + 'type' => DatabaseBlock::TYPE_IP, + 'desc' => 'Exact Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ], + ]; + + $blocker = $this->getTestUser()->getUser(); + foreach ( $blockList as $insBlock ) { + $target = $insBlock['target']; + + if ( $insBlock['type'] === DatabaseBlock::TYPE_IP ) { + $target = User::newFromName( IP::sanitizeIP( $target ), false )->getName(); + } elseif ( $insBlock['type'] === DatabaseBlock::TYPE_RANGE ) { + $target = IP::sanitizeRange( $target ); + } + + $block = new DatabaseBlock(); + $block->setTarget( $target ); + $block->setBlocker( $blocker ); + $block->setReason( $insBlock['desc'] ); + $block->setExpiry( 'infinity' ); + $block->isCreateAccountBlocked( $insBlock['ACDisable'] ); + $block->isHardblock( $insBlock['isHardblock'] ); + $block->isAutoblocking( $insBlock['isAutoBlocking'] ); + $block->insert(); + } + } + + public static function providerXff() { + return [ + [ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ], + [ 'xff' => '1.2.3.4, 50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ], + [ 'xff' => '1.2.3.4, 70.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ], + [ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 3, + 'result' => 'Exact Softblock' + ], + [ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ], + [ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ], + [ 'xff' => '50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ], + [ 'xff' => '1.2.3.4, 50.1.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ], + [ 'xff' => '1.2.3.4, <$A_BUNCH-OF{INVALID}TEXT\>, 60.2.1.1, 2.3.4.5', + 'count' => 1, + 'result' => 'Range Softblock with AC Disabled' + ], + [ 'xff' => '1.2.3.4, 50.2.1.1, 2001:4860:4001:802::1003, 2.3.4.5', + 'count' => 2, + 'result' => 'Range6 Hardblock' + ], + ]; + } + + /** + * @dataProvider providerXff + * @covers ::getBlocksForIPList + * @covers ::chooseBlock + */ + public function testBlocksOnXff( $xff, $exCount, $exResult ) { + $user = $this->getUserForBlocking(); + $this->addBlockForUser( $user ); + + $list = array_map( 'trim', explode( ',', $xff ) ); + $xffblocks = DatabaseBlock::getBlocksForIPList( $list, true ); + $this->assertEquals( $exCount, count( $xffblocks ), 'Number of blocks for ' . $xff ); + $block = DatabaseBlock::chooseBlock( $xffblocks, $list ); + $this->assertEquals( + $exResult, $block->getReason(), 'Correct block type for XFF header ' . $xff + ); + } + + /** + * @covers ::newFromRow + */ + public function testNewFromRow() { + $badActor = $this->getTestUser()->getUser(); + $sysop = $this->getTestSysop()->getUser(); + + $block = new DatabaseBlock( [ + 'address' => $badActor->getName(), + 'user' => $badActor->getId(), + 'by' => $sysop->getId(), + 'expiry' => 'infinity', + ] ); + $block->insert(); + + $blockQuery = DatabaseBlock::getQueryInfo(); + $row = $this->db->select( + $blockQuery['tables'], + $blockQuery['fields'], + [ + 'ipb_id' => $block->getId(), + ], + __METHOD__, + [], + $blockQuery['joins'] + )->fetchObject(); + + $block = DatabaseBlock::newFromRow( $row ); + $this->assertInstanceOf( DatabaseBlock::class, $block ); + $this->assertEquals( $block->getBy(), $sysop->getId() ); + $this->assertEquals( $block->getTarget()->getName(), $badActor->getName() ); + $block->delete(); + } + + /** + * @covers ::equals + */ + public function testEquals() { + $block = new DatabaseBlock(); + + $this->assertTrue( $block->equals( $block ) ); + + $partial = new DatabaseBlock( [ + 'sitewide' => false, + ] ); + $this->assertFalse( $block->equals( $partial ) ); + } + + /** + * @covers ::isSitewide + */ + public function testIsSitewide() { + $block = new DatabaseBlock(); + $this->assertTrue( $block->isSitewide() ); + + $block = new DatabaseBlock( [ + 'sitewide' => true, + ] ); + $this->assertTrue( $block->isSitewide() ); + + $block = new DatabaseBlock( [ + 'sitewide' => false, + ] ); + $this->assertFalse( $block->isSitewide() ); + + $block = new DatabaseBlock( [ + 'sitewide' => false, + ] ); + $block->isSitewide( true ); + $this->assertTrue( $block->isSitewide() ); + } + + /** + * @covers ::getRestrictions + * @covers ::setRestrictions + */ + public function testRestrictions() { + $block = new DatabaseBlock(); + $restrictions = [ + new PageRestriction( 0, 1 ) + ]; + $block->setRestrictions( $restrictions ); + + $this->assertSame( $restrictions, $block->getRestrictions() ); + } + + /** + * @covers ::getRestrictions + * @covers ::insert + */ + public function testRestrictionsFromDatabase() { + $badActor = $this->getTestUser()->getUser(); + $sysop = $this->getTestSysop()->getUser(); + + $block = new DatabaseBlock( [ + 'address' => $badActor->getName(), + 'user' => $badActor->getId(), + 'by' => $sysop->getId(), + 'expiry' => 'infinity', + ] ); + $page = $this->getExistingTestPage( 'Foo' ); + $restriction = new PageRestriction( 0, $page->getId() ); + $block->setRestrictions( [ $restriction ] ); + $block->insert(); + + // Refresh the block from the database. + $block = DatabaseBlock::newFromID( $block->getId() ); + $restrictions = $block->getRestrictions(); + $this->assertCount( 1, $restrictions ); + $this->assertTrue( $restriction->equals( $restrictions[0] ) ); + $block->delete(); + } + + /** + * @covers ::insert + */ + public function testInsertExistingBlock() { + $badActor = $this->getTestUser()->getUser(); + $sysop = $this->getTestSysop()->getUser(); + + $block = new DatabaseBlock( [ + 'address' => $badActor->getName(), + 'user' => $badActor->getId(), + 'by' => $sysop->getId(), + 'expiry' => 'infinity', + ] ); + $page = $this->getExistingTestPage( 'Foo' ); + $restriction = new PageRestriction( 0, $page->getId() ); + $block->setRestrictions( [ $restriction ] ); + $block->insert(); + + // Insert the block again, which should result in a failur + $result = $block->insert(); + + $this->assertFalse( $result ); + + // Ensure that there are no restrictions where the blockId is 0. + $count = $this->db->selectRowCount( + 'ipblocks_restrictions', + '*', + [ 'ir_ipb_id' => 0 ], + __METHOD__ + ); + $this->assertSame( 0, $count ); + + $block->delete(); + } + + /** + * @covers ::appliesToTitle + */ + public function testAppliesToTitleReturnsTrueOnSitewideBlock() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $user = $this->getTestUser()->getUser(); + $block = new DatabaseBlock( [ + 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), + 'allowUsertalk' => true, + 'sitewide' => true + ] ); + + $block->setTarget( $user ); + $block->setBlocker( $this->getTestSysop()->getUser() ); + $block->insert(); + + $title = $this->getExistingTestPage( 'Foo' )->getTitle(); + + $this->assertTrue( $block->appliesToTitle( $title ) ); + + // appliesToTitle() ignores allowUsertalk + $title = $user->getTalkPage(); + $this->assertTrue( $block->appliesToTitle( $title ) ); + + $block->delete(); + } + + /** + * @covers ::appliesToTitle + */ + public function testAppliesToTitleOnPartialBlock() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $user = $this->getTestUser()->getUser(); + $block = new DatabaseBlock( [ + 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), + 'allowUsertalk' => true, + 'sitewide' => false + ] ); + + $block->setTarget( $user ); + $block->setBlocker( $this->getTestSysop()->getUser() ); + $block->insert(); + + $pageFoo = $this->getExistingTestPage( 'Foo' ); + $pageBar = $this->getExistingTestPage( 'Bar' ); + $pageJohn = $this->getExistingTestPage( 'User:John' ); + + $pageRestriction = new PageRestriction( $block->getId(), $pageFoo->getId() ); + $namespaceRestriction = new NamespaceRestriction( $block->getId(), NS_USER ); + $this->getBlockRestrictionStore()->insert( [ $pageRestriction, $namespaceRestriction ] ); + + $this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) ); + $this->assertFalse( $block->appliesToTitle( $pageBar->getTitle() ) ); + $this->assertTrue( $block->appliesToTitle( $pageJohn->getTitle() ) ); + + $block->delete(); + } + + /** + * @covers ::appliesToNamespace + * @covers ::appliesToPage + */ + public function testAppliesToReturnsTrueOnSitewideBlock() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $user = $this->getTestUser()->getUser(); + $block = new DatabaseBlock( [ + 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), + 'allowUsertalk' => true, + 'sitewide' => true + ] ); + + $block->setTarget( $user ); + $block->setBlocker( $this->getTestSysop()->getUser() ); + $block->insert(); + + $title = $this->getExistingTestPage()->getTitle(); + + $this->assertTrue( $block->appliesToPage( $title->getArticleID() ) ); + $this->assertTrue( $block->appliesToNamespace( NS_MAIN ) ); + $this->assertTrue( $block->appliesToNamespace( NS_USER_TALK ) ); + + $block->delete(); + } + + /** + * @covers ::appliesToPage + */ + public function testAppliesToPageOnPartialPageBlock() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $user = $this->getTestUser()->getUser(); + $block = new DatabaseBlock( [ + 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), + 'allowUsertalk' => true, + 'sitewide' => false + ] ); + + $block->setTarget( $user ); + $block->setBlocker( $this->getTestSysop()->getUser() ); + $block->insert(); + + $title = $this->getExistingTestPage()->getTitle(); + + $pageRestriction = new PageRestriction( + $block->getId(), + $title->getArticleID() + ); + $this->getBlockRestrictionStore()->insert( [ $pageRestriction ] ); + + $this->assertTrue( $block->appliesToPage( $title->getArticleID() ) ); + + $block->delete(); + } + + /** + * @covers ::appliesToNamespace + */ + public function testAppliesToNamespaceOnPartialNamespaceBlock() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $user = $this->getTestUser()->getUser(); + $block = new DatabaseBlock( [ + 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), + 'allowUsertalk' => true, + 'sitewide' => false + ] ); + + $block->setTarget( $user ); + $block->setBlocker( $this->getTestSysop()->getUser() ); + $block->insert(); + + $namespaceRestriction = new NamespaceRestriction( $block->getId(), NS_MAIN ); + $this->getBlockRestrictionStore()->insert( [ $namespaceRestriction ] ); + + $this->assertTrue( $block->appliesToNamespace( NS_MAIN ) ); + $this->assertFalse( $block->appliesToNamespace( NS_USER ) ); + + $block->delete(); + } + + /** + * @covers ::appliesToRight + */ + public function testBlockAllowsPurge() { + $this->setMwGlobals( [ + 'wgBlockDisablesLogin' => false, + ] ); + $block = new DatabaseBlock(); + $this->assertFalse( $block->appliesToRight( 'purge' ) ); + } + + /** + * Get an instance of BlockRestrictionStore + * + * @return BlockRestrictionStore + */ + protected function getBlockRestrictionStore() : BlockRestrictionStore { + return MediaWikiServices::getInstance()->getBlockRestrictionStore(); + } +} diff --git a/tests/phpunit/includes/cache/GenderCacheTest.php b/tests/phpunit/includes/cache/GenderCacheTest.php index 5c1a925eac..fbce1619dc 100644 --- a/tests/phpunit/includes/cache/GenderCacheTest.php +++ b/tests/phpunit/includes/cache/GenderCacheTest.php @@ -1,4 +1,5 @@ 'some_type', - 'name' => 'group_name', - 'priority' => 1, - 'filters' => [], - ] - ); - } - - public function testAutoPriorities() { - $group = new MockChangesListFilterGroup( - [ - 'type' => 'some_type', - 'name' => 'groupName', - 'isFullCoverage' => true, - 'priority' => 1, - 'filters' => [ - [ 'name' => 'hidefoo' ], - [ 'name' => 'hidebar' ], - [ 'name' => 'hidebaz' ], - ], - ] - ); - - $filters = $group->getFilters(); - $this->assertEquals( - [ - -2, - -3, - -4, - ], - array_map( - function ( $f ) { - return $f->getPriority(); - }, - array_values( $filters ) - ) - ); - } - - // Get without warnings - public function testGetFilter() { - $group = new MockChangesListFilterGroup( - [ - 'type' => 'some_type', - 'name' => 'groupName', - 'isFullCoverage' => true, - 'priority' => 1, - 'filters' => [ - [ 'name' => 'foo' ], - ], - ] - ); - - $this->assertEquals( - 'foo', - $group->getFilter( 'foo' )->getName() - ); - - $this->assertEquals( - null, - $group->getFilter( 'bar' ) - ); - } -} diff --git a/tests/phpunit/includes/changes/OldChangesListTest.php b/tests/phpunit/includes/changes/OldChangesListTest.php index 91dc731224..f4048b4f0a 100644 --- a/tests/phpunit/includes/changes/OldChangesListTest.php +++ b/tests/phpunit/includes/changes/OldChangesListTest.php @@ -143,7 +143,7 @@ class OldChangesListTest extends MediaWikiLangTestCase { $recentChange->numberofWatchingusers = 100; $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); - $this->assertRegExp( "/(number_of_watching_users_RCview: 100)/", $line ); + $this->assertRegExp( "/(number-of-watching-users-for-recent-changes: 100)/", $line ); } public function testRecentChangesLine_watchlistCssClass() { diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php index 333eb28610..0699db821f 100644 --- a/tests/phpunit/includes/changes/RecentChangeTest.php +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -1,4 +1,5 @@ register( 'unittest', 'GlobalVarConfig::newInstance' ); - $this->assertInstanceOf( GlobalVarConfig::class, $factory->makeConfig( 'unittest' ) ); - } - - /** - * @covers ConfigFactory::register - */ - public function testRegisterInvalid() { - $factory = new ConfigFactory(); - $this->setExpectedException( InvalidArgumentException::class ); - $factory->register( 'invalid', 'Invalid callback' ); - } - - /** - * @covers ConfigFactory::register - */ - public function testRegisterInvalidInstance() { - $factory = new ConfigFactory(); - $this->setExpectedException( InvalidArgumentException::class ); - $factory->register( 'invalidInstance', new stdClass ); - } - - /** - * @covers ConfigFactory::register - */ - public function testRegisterInstance() { - $config = GlobalVarConfig::newInstance(); - $factory = new ConfigFactory(); - $factory->register( 'unittest', $config ); - $this->assertSame( $config, $factory->makeConfig( 'unittest' ) ); - } - - /** - * @covers ConfigFactory::register - */ - public function testRegisterAgain() { - $factory = new ConfigFactory(); - $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); - $config1 = $factory->makeConfig( 'unittest' ); - - $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); - $config2 = $factory->makeConfig( 'unittest' ); - - $this->assertNotSame( $config1, $config2 ); - } - - /** - * @covers ConfigFactory::salvage - */ - public function testSalvage() { - $oldFactory = new ConfigFactory(); - $oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); - $oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' ); - $oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' ); - - // instantiate two of the three defined configurations - $foo = $oldFactory->makeConfig( 'foo' ); - $bar = $oldFactory->makeConfig( 'bar' ); - $quux = $oldFactory->makeConfig( 'quux' ); - - // define new config instance - $newFactory = new ConfigFactory(); - $newFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); - $newFactory->register( 'bar', function () { - return new HashConfig(); - } ); - - // "foo" and "quux" are defined in the old and the new factory. - // The old factory has instances for "foo" and "bar", but not "quux". - $newFactory->salvage( $oldFactory ); - - $newFoo = $newFactory->makeConfig( 'foo' ); - $this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' ); - - $newBar = $newFactory->makeConfig( 'bar' ); - $this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' ); - - // the new factory doesn't have quux defined, so the quux instance should not be salvaged - $this->setExpectedException( ConfigException::class ); - $newFactory->makeConfig( 'quux' ); - } - - /** - * @covers ConfigFactory::getConfigNames - */ - public function testGetConfigNames() { - $factory = new ConfigFactory(); - $factory->register( 'foo', 'GlobalVarConfig::newInstance' ); - $factory->register( 'bar', new HashConfig() ); - - $this->assertEquals( [ 'foo', 'bar' ], $factory->getConfigNames() ); - } - - /** - * @covers ConfigFactory::makeConfig - */ - public function testMakeConfigWithCallback() { - $factory = new ConfigFactory(); - $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); - - $conf = $factory->makeConfig( 'unittest' ); - $this->assertInstanceOf( Config::class, $conf ); - $this->assertSame( $conf, $factory->makeConfig( 'unittest' ) ); - } - - /** - * @covers ConfigFactory::makeConfig - */ - public function testMakeConfigWithObject() { - $factory = new ConfigFactory(); - $conf = new HashConfig(); - $factory->register( 'test', $conf ); - $this->assertSame( $conf, $factory->makeConfig( 'test' ) ); - } - - /** - * @covers ConfigFactory::makeConfig - */ - public function testMakeConfigFallback() { - $factory = new ConfigFactory(); - $factory->register( '*', 'GlobalVarConfig::newInstance' ); - $conf = $factory->makeConfig( 'unittest' ); - $this->assertInstanceOf( Config::class, $conf ); - } - - /** - * @covers ConfigFactory::makeConfig - */ - public function testMakeConfigWithNoBuilders() { - $factory = new ConfigFactory(); - $this->setExpectedException( ConfigException::class ); - $factory->makeConfig( 'nobuilderregistered' ); - } - - /** - * @covers ConfigFactory::makeConfig - */ - public function testMakeConfigWithInvalidCallback() { - $factory = new ConfigFactory(); - $factory->register( 'unittest', function () { - return true; // Not a Config object - } ); - $this->setExpectedException( UnexpectedValueException::class ); - $factory->makeConfig( 'unittest' ); - } - - /** - * @covers ConfigFactory::getDefaultInstance - */ - public function testGetDefaultInstance() { - // NOTE: the global config factory returned here has been overwritten - // for operation in test mode. It may not reflect LocalSettings. - $factory = MediaWikiServices::getInstance()->getConfigFactory(); - $this->assertInstanceOf( Config::class, $factory->makeConfig( 'main' ) ); - } - -} diff --git a/tests/phpunit/includes/config/HashConfigTest.php b/tests/phpunit/includes/config/HashConfigTest.php deleted file mode 100644 index bac8311cd4..0000000000 --- a/tests/phpunit/includes/config/HashConfigTest.php +++ /dev/null @@ -1,63 +0,0 @@ -assertInstanceOf( HashConfig::class, $conf ); - } - - /** - * @covers HashConfig::__construct - */ - public function testConstructor() { - $conf = new HashConfig(); - $this->assertInstanceOf( HashConfig::class, $conf ); - - // Test passing arguments to the constructor - $conf2 = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertEquals( '1', $conf2->get( 'one' ) ); - } - - /** - * @covers HashConfig::get - */ - public function testGet() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertEquals( '1', $conf->get( 'one' ) ); - $this->setExpectedException( ConfigException::class, 'HashConfig::get: undefined option' ); - $conf->get( 'two' ); - } - - /** - * @covers HashConfig::has - */ - public function testHas() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $this->assertTrue( $conf->has( 'one' ) ); - $this->assertFalse( $conf->has( 'two' ) ); - } - - /** - * @covers HashConfig::set - */ - public function testSet() { - $conf = new HashConfig( [ - 'one' => '1', - ] ); - $conf->set( 'two', '2' ); - $this->assertEquals( '2', $conf->get( 'two' ) ); - // Check that set overwrites - $conf->set( 'one', '3' ); - $this->assertEquals( '3', $conf->get( 'one' ) ); - } -} diff --git a/tests/phpunit/includes/config/MultiConfigTest.php b/tests/phpunit/includes/config/MultiConfigTest.php deleted file mode 100644 index fc2839513b..0000000000 --- a/tests/phpunit/includes/config/MultiConfigTest.php +++ /dev/null @@ -1,39 +0,0 @@ - 'bar' ] ), - new HashConfig( [ 'foo' => 'baz', 'bar' => 'foo' ] ), - new HashConfig( [ 'bar' => 'baz' ] ), - ] ); - - $this->assertEquals( 'bar', $multi->get( 'foo' ) ); - $this->assertEquals( 'foo', $multi->get( 'bar' ) ); - $this->setExpectedException( ConfigException::class, 'MultiConfig::get: undefined option:' ); - $multi->get( 'notset' ); - } - - /** - * @covers MultiConfig::has - */ - public function testHas() { - $conf = new MultiConfig( [ - new HashConfig( [ 'foo' => 'foo' ] ), - new HashConfig( [ 'something' => 'bleh' ] ), - new HashConfig( [ 'meh' => 'eh' ] ), - ] ); - - $this->assertTrue( $conf->has( 'foo' ) ); - $this->assertTrue( $conf->has( 'something' ) ); - $this->assertTrue( $conf->has( 'meh' ) ); - $this->assertFalse( $conf->has( 'what' ) ); - } -} diff --git a/tests/phpunit/includes/config/ServiceOptionsTest.php b/tests/phpunit/includes/config/ServiceOptionsTest.php deleted file mode 100644 index 966cf411c7..0000000000 --- a/tests/phpunit/includes/config/ServiceOptionsTest.php +++ /dev/null @@ -1,149 +0,0 @@ - $val ) { - $this->assertSame( $val, $options->get( $key ) ); - } - - // This is lumped in the same test because there's no support for depending on a test that - // has a data provider. - $options->assertRequiredOptions( array_keys( $expected ) ); - - // Suppress warning if no assertions were run. This is expected for empty arguments. - $this->assertTrue( true ); - } - - public function provideConstructor() { - return [ - 'No keys' => [ [], [], [ 'a' => 'aval' ] ], - 'Simple array source' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ], - ], - 'Simple HashConfig source' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - new HashConfig( [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ] ), - ], - 'Three different sources' => [ - [ 'a' => 'aval', 'b' => 'bval' ], - [ 'a', 'b' ], - [ 'z' => 'zval' ], - new HashConfig( [ 'a' => 'aval', 'c' => 'cval' ] ), - [ 'b' => 'bval', 'd' => 'dval' ], - ], - 'null key' => [ - [ 'a' => null ], - [ 'a' ], - [ 'a' => null ], - ], - 'Numeric option name' => [ - [ '0' => 'nothing' ], - [ '0' ], - [ '0' => 'nothing' ], - ], - 'Multiple sources for one key' => [ - [ 'a' => 'winner' ], - [ 'a' ], - [ 'a' => 'winner' ], - [ 'a' => 'second place' ], - ], - 'Object value is passed by reference' => [ - [ 'a' => self::$testObj ], - [ 'a' ], - [ 'a' => self::$testObj ], - ], - ]; - } - - /** - * @covers ::__construct - */ - public function testKeyNotFound() { - $this->setExpectedException( InvalidArgumentException::class, - 'Key "a" not found in input sources' ); - - new ServiceOptions( [ 'a' ], [ 'b' => 'bval' ], [ 'c' => 'cval' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testOutOfOrderAssertRequiredOptions() { - $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); - $options->assertRequiredOptions( [ 'b', 'a' ] ); - $this->assertTrue( true, 'No exception thrown' ); - } - - /** - * @covers ::__construct - * @covers ::get - */ - public function testGetUnrecognized() { - $this->setExpectedException( InvalidArgumentException::class, - 'Unrecognized option "b"' ); - - $options = new ServiceOptions( [ 'a' ], [ 'a' => '' ] ); - $options->get( 'b' ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testExtraKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Unsupported options passed: b, c!' ); - - $options = new ServiceOptions( [ 'a', 'b', 'c' ], [ 'a' => '', 'b' => '', 'c' => '' ] ); - $options->assertRequiredOptions( [ 'a' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testMissingKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Required options missing: a, b!' ); - - $options = new ServiceOptions( [ 'c' ], [ 'c' => '' ] ); - $options->assertRequiredOptions( [ 'a', 'b', 'c' ] ); - } - - /** - * @covers ::__construct - * @covers ::assertRequiredOptions - */ - public function testExtraAndMissingKeys() { - $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, - 'Precondition failed: Unsupported options passed: b! Required options missing: c!' ); - - $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); - $options->assertRequiredOptions( [ 'a', 'c' ] ); - } -} diff --git a/tests/phpunit/includes/content/ContentHandlerTest.php b/tests/phpunit/includes/content/ContentHandlerTest.php index 4eecd4dac1..e73b554561 100644 --- a/tests/phpunit/includes/content/ContentHandlerTest.php +++ b/tests/phpunit/includes/content/ContentHandlerTest.php @@ -1,4 +1,5 @@ makeEmptyContent(); - $this->assertInstanceOf( JsonContent::class, $content ); - $this->assertTrue( $content->isValid() ); - } -} diff --git a/tests/phpunit/includes/content/JsonContentTest.php b/tests/phpunit/includes/content/JsonContentTest.php index 8546d967a6..8d6c3aecf3 100644 --- a/tests/phpunit/includes/content/JsonContentTest.php +++ b/tests/phpunit/includes/content/JsonContentTest.php @@ -92,6 +92,7 @@ class JsonContentTest extends MediaWikiLangTestCase { ->disableOriginalConstructor() ->getMock(); } + private function getMockParserOptions() { return $this->getMockBuilder( ParserOptions::class ) ->disableOriginalConstructor() diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php deleted file mode 100644 index 63b24dc688..0000000000 --- a/tests/phpunit/includes/db/DatabaseSqliteTest.php +++ /dev/null @@ -1,544 +0,0 @@ -markTestSkipped( 'No SQLite support detected' ); - } - $this->db = DatabaseSqliteMock::newInstance(); - if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { - $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); - } - } - - private function replaceVars( $sql ) { - // normalize spacing to hide implementation details - return preg_replace( '/\s+/', ' ', $this->db->replaceVars( $sql ) ); - } - - private function assertResultIs( $expected, $res ) { - $this->assertNotNull( $res ); - $i = 0; - foreach ( $res as $row ) { - foreach ( $expected[$i] as $key => $value ) { - $this->assertTrue( isset( $row->$key ) ); - $this->assertEquals( $value, $row->$key ); - } - $i++; - } - $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); - } - - public static function provideAddQuotes() { - return [ - [ // #0: empty - '', "''" - ], - [ // #1: simple - 'foo bar', "'foo bar'" - ], - [ // #2: including quote - 'foo\'bar', "'foo''bar'" - ], - // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) - [ - "x\0y", - "x'780079'", - ], - [ // #4: blob object (must be represented as hex) - new Blob( "hello" ), - "x'68656c6c6f'", - ], - [ // #5: null - null, - "''", - ], - ]; - } - - /** - * @dataProvider provideAddQuotes() - * @covers DatabaseSqlite::addQuotes - */ - public function testAddQuotes( $value, $expected ) { - // check quoting - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); - - // ok, quoting works as expected, now try a round trip. - $re = $db->query( 'select ' . $db->addQuotes( $value ) ); - - $this->assertTrue( $re !== false, 'query failed' ); - - $row = $re->fetchRow(); - if ( $row ) { - if ( $value instanceof Blob ) { - $value = $value->fetch(); - } - - $this->assertEquals( $value, $row[0], 'string mangled by the database' ); - } else { - $this->fail( 'query returned no result' ); - } - } - - /** - * @covers DatabaseSqlite::replaceVars - */ - public function testReplaceVars() { - $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); - - $this->assertEquals( - "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " - . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", - $this->replaceVars( - "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " - . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " - . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" - ) - ); - - $this->assertEquals( - "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", - $this->replaceVars( - "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" - ) - ); - - $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", - $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) - ); - - $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", - $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), - 'Table name changed' - ); - - $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", - $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) - ); - $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", - $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) - ); - - $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", - $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) - ); - - $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", - $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) - ); - - $this->assertEquals( "DROP INDEX foo", - $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) - ); - - $this->assertEquals( "DROP INDEX foo -- dropping index", - $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) - ); - $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", - $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) - ); - } - - /** - * @covers DatabaseSqlite::tableName - */ - public function testTableName() { - // @todo Moar! - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); - $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); - $db->tablePrefix( 'foo_' ); - $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); - $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) ); - } - - /** - * @covers DatabaseSqlite::duplicateTableStructure - */ - public function testDuplicateTableStructure() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE foo(foo, barfoo)' ); - $db->query( 'CREATE INDEX index1 ON foo(foo)' ); - $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' ); - - $db->duplicateTableStructure( 'foo', 'bar' ); - $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), - 'Normal table duplication' - ); - $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' ); - $index = $indexList->next(); - $this->assertEquals( 'bar_index1', $index->name ); - $this->assertEquals( '0', $index->unique ); - $index = $indexList->next(); - $this->assertEquals( 'bar_index2', $index->name ); - $this->assertEquals( '1', $index->unique ); - - $db->duplicateTableStructure( 'foo', 'baz', true ); - $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', - $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ), - 'Creation of temporary duplicate' - ); - $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' ); - $index = $indexList->next(); - $this->assertEquals( 'baz_index1', $index->name ); - $this->assertEquals( '0', $index->unique ); - $index = $indexList->next(); - $this->assertEquals( 'baz_index2', $index->name ); - $this->assertEquals( '1', $index->unique ); - $this->assertEquals( 0, - $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ), - 'Create a temporary duplicate only' - ); - } - - /** - * @covers DatabaseSqlite::duplicateTableStructure - */ - public function testDuplicateTableStructureVirtual() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - if ( $db->getFulltextSearchModule() != 'FTS3' ) { - $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); - } - $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); - - $db->duplicateTableStructure( 'foo', 'bar' ); - $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), - 'Duplication of virtual tables' - ); - - $db->duplicateTableStructure( 'foo', 'baz', true ); - $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', - $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ), - "Can't create temporary virtual tables, should fall back to non-temporary duplication" - ); - } - - /** - * @covers DatabaseSqlite::deleteJoin - */ - public function testDeleteJoin() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); - $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); - $db->insert( 'a', [ - [ 'a_1' => 1 ], - [ 'a_1' => 2 ], - [ 'a_1' => 3 ], - ], - __METHOD__ - ); - $db->insert( 'b', [ - [ 'b_1' => 2, 'b_2' => 'a' ], - [ 'b_1' => 3, 'b_2' => 'b' ], - ], - __METHOD__ - ); - $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ ); - $res = $db->query( "SELECT * FROM a", __METHOD__ ); - $this->assertResultIs( [ - [ 'a_1' => 1 ], - [ 'a_1' => 3 ], - ], - $res - ); - } - - /** - * @coversNothing - */ - public function testEntireSchema() { - global $IP; - - $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); - if ( $result !== true ) { - $this->fail( $result ); - } - $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions - } - - /** - * Runs upgrades of older databases and compares results with current schema - * @todo Currently only checks list of tables - * @coversNothing - */ - public function testUpgrades() { - global $IP, $wgVersion, $wgProfiler; - - // Versions tested - $versions = [ - // '1.13', disabled for now, was totally screwed up - // SQLite wasn't included in 1.14 - '1.15', - '1.16', - '1.17', - '1.18', - '1.19', - '1.20', - '1.21', - '1.22', - '1.23', - ]; - - // Mismatches for these columns we can safely ignore - $ignoredColumns = [ - 'user_newtalk.user_last_timestamp', // r84185 - ]; - - $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); - - $profileToDb = false; - if ( isset( $wgProfiler['output'] ) ) { - $out = $wgProfiler['output']; - if ( $out === 'db' ) { - $profileToDb = true; - } elseif ( is_array( $out ) && in_array( 'db', $out ) ) { - $profileToDb = true; - } - } - - if ( $profileToDb ) { - $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); - } - $currentTables = $this->getTables( $currentDB ); - sort( $currentTables ); - - foreach ( $versions as $version ) { - $versions = "upgrading from $version to $wgVersion"; - $db = $this->prepareTestDB( $version ); - $tables = $this->getTables( $db ); - $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); - foreach ( $tables as $table ) { - $currentCols = $this->getColumns( $currentDB, $table ); - $cols = $this->getColumns( $db, $table ); - $this->assertEquals( - array_keys( $currentCols ), - array_keys( $cols ), - "Mismatching columns for table \"$table\" $versions" - ); - foreach ( $currentCols as $name => $column ) { - $fullName = "$table.$name"; - $this->assertEquals( - (bool)$column->pk, - (bool)$cols[$name]->pk, - "PRIMARY KEY status does not match for column $fullName $versions" - ); - if ( !in_array( $fullName, $ignoredColumns ) ) { - $this->assertEquals( - (bool)$column->notnull, - (bool)$cols[$name]->notnull, - "NOT NULL status does not match for column $fullName $versions" - ); - $this->assertEquals( - $column->dflt_value, - $cols[$name]->dflt_value, - "Default values does not match for column $fullName $versions" - ); - } - } - $currentIndexes = $this->getIndexes( $currentDB, $table ); - $indexes = $this->getIndexes( $db, $table ); - $this->assertEquals( - array_keys( $currentIndexes ), - array_keys( $indexes ), - "mismatching indexes for table \"$table\" $versions" - ); - } - $db->close(); - } - } - - /** - * @covers DatabaseSqlite::insertId - */ - public function testInsertIdType() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); - $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" ); - - $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); - $this->assertTrue( $insertion, "Insertion worked" ); - - $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); - $this->assertTrue( $db->close(), "closing database" ); - } - - /** - * @covers DatabaseSqlite::insert - */ - public function testInsertAffectedRows() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ ); - - $insertion = $db->insert( - 'testInsertAffectedRows', - [ - [ 'foo' => 10 ], - [ 'foo' => 12 ], - [ 'foo' => 1555 ], - ], - __METHOD__ - ); - $this->assertTrue( $insertion, "Insertion worked" ); - - $this->assertSame( 3, $db->affectedRows() ); - $this->assertTrue( $db->close(), "closing database" ); - } - - private function prepareTestDB( $version ) { - static $maint = null; - if ( $maint === null ) { - $maint = new FakeMaintenance(); - $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] ); - } - - global $IP; - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); - $updater = DatabaseUpdater::newForDB( $db, false, $maint ); - $updater->doUpdates( [ 'core' ] ); - - return $db; - } - - private function getTables( $db ) { - $list = array_flip( $db->listTables() ); - $excluded = [ - 'external_user', // removed from core in 1.22 - 'math', // moved out of core in 1.18 - 'trackbacks', // removed from core in 1.19 - 'searchindex', - 'searchindex_content', - 'searchindex_segments', - 'searchindex_segdir', - // FTS4 ready!!1 - 'searchindex_docsize', - 'searchindex_stat', - ]; - foreach ( $excluded as $t ) { - unset( $list[$t] ); - } - $list = array_flip( $list ); - sort( $list ); - - return $list; - } - - private function getColumns( $db, $table ) { - $cols = []; - $res = $db->query( "PRAGMA table_info($table)" ); - $this->assertNotNull( $res ); - foreach ( $res as $col ) { - $cols[$col->name] = $col; - } - ksort( $cols ); - - return $cols; - } - - private function getIndexes( $db, $table ) { - $indexes = []; - $res = $db->query( "PRAGMA index_list($table)" ); - $this->assertNotNull( $res ); - foreach ( $res as $index ) { - $res2 = $db->query( "PRAGMA index_info({$index->name})" ); - $this->assertNotNull( $res2 ); - $index->columns = []; - foreach ( $res2 as $col ) { - $index->columns[] = $col; - } - $indexes[$index->name] = $index; - } - ksort( $indexes ); - - return $indexes; - } - - /** - * @coversNothing - */ - public function testCaseInsensitiveLike() { - // TODO: Test this for all databases - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); - $row = $res->fetchRow(); - $this->assertFalse( (bool)$row['a'] ); - } - - /** - * @covers DatabaseSqlite::numFields - */ - public function testNumFields() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); - $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" ); - $res = $db->select( 'a', '*' ); - $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); - $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); - $this->assertTrue( $insertion, "Insertion failed" ); - $res = $db->select( 'a', '*' ); - $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); - - $this->assertTrue( $db->close(), "closing database" ); - } - - /** - * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString - */ - public function testToString() { - $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); - - $toString = (string)$db; - - $this->assertContains( 'SQLite ', $toString ); - } - - /** - * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes() - */ - public function testsAttributes() { - $attributes = Database::attributesFromType( 'sqlite' ); - $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ); - } -} - -class DatabaseSqliteMock extends DatabaseSqlite { - public static function newInstance( array $p = [] ) { - $p['dbFilePath'] = ':memory:'; - $p['schema'] = false; - - return Database::factory( 'SqliteMock', $p ); - } - - function query( $sql, $fname = '', $flags = 0 ) { - return true; - } - - /** - * Override parent visibility to public - */ - public function replaceVars( $s ) { - return parent::replaceVars( $s ); - } -} diff --git a/tests/phpunit/includes/db/DatabaseTestHelper.php b/tests/phpunit/includes/db/DatabaseTestHelper.php index fb4041dd92..43e7075ee3 100644 --- a/tests/phpunit/includes/db/DatabaseTestHelper.php +++ b/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -229,10 +229,6 @@ class DatabaseTestHelper extends Database { return 'test'; } - function isOpen() { - return $this->conn ? true : false; - } - function ping( &$rtt = null ) { $rtt = 0.0; return true; diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php index 106a13bc77..123b080b9a 100644 --- a/tests/phpunit/includes/db/LBFactoryTest.php +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -302,6 +302,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->getMock(); $lb1->method( 'getConnection' )->willReturn( $mockDB1 ); $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'hasReplicaServers' )->willReturn( true ); + $lb1->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb1->method( 'getAnyOpenConnection' )->willReturn( $mockDB1 ); $lb1->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( function () use ( $mockDB1 ) { @@ -327,6 +329,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->getMock(); $lb2->method( 'getConnection' )->willReturn( $mockDB2 ); $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'hasReplicaServers' )->willReturn( true ); + $lb2->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb2->method( 'getAnyOpenConnection' )->willReturn( $mockDB2 ); $lb2->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( function () use ( $mockDB2 ) { @@ -356,11 +360,11 @@ class LBFactoryTest extends MediaWikiTestCase { $mockDB2->expects( $this->exactly( 1 ) )->method( 'lastDoneWrites' ); // Nothing to wait for on first HTTP request start - $cp->initLB( $lb1 ); - $cp->initLB( $lb2 ); + $cp->applySessionReplicationPosition( $lb1 ); + $cp->applySessionReplicationPosition( $lb2 ); // Record positions in stash on first HTTP request end - $cp->shutdownLB( $lb1 ); - $cp->shutdownLB( $lb2 ); + $cp->storeSessionReplicationPosition( $lb1 ); + $cp->storeSessionReplicationPosition( $lb2 ); $cpIndex = null; $cp->shutdown( null, 'sync', $cpIndex ); @@ -373,6 +377,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->disableOriginalConstructor() ->getMock(); $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'hasReplicaServers' )->willReturn( true ); + $lb1->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' ); $lb1->expects( $this->once() ) ->method( 'waitFor' )->with( $this->equalTo( $m1Pos ) ); @@ -381,6 +387,8 @@ class LBFactoryTest extends MediaWikiTestCase { ->disableOriginalConstructor() ->getMock(); $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'hasReplicaServers' )->willReturn( true ); + $lb2->method( 'hasStreamingReplicaServers' )->willReturn( true ); $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' ); $lb2->expects( $this->once() ) ->method( 'waitFor' )->with( $this->equalTo( $m2Pos ) ); @@ -395,11 +403,11 @@ class LBFactoryTest extends MediaWikiTestCase { ); // Wait for last positions to be reached on second HTTP request start - $cp->initLB( $lb1 ); - $cp->initLB( $lb2 ); + $cp->applySessionReplicationPosition( $lb1 ); + $cp->applySessionReplicationPosition( $lb2 ); // Shutdown (nothing to record) - $cp->shutdownLB( $lb1 ); - $cp->shutdownLB( $lb2 ); + $cp->storeSessionReplicationPosition( $lb1 ); + $cp->storeSessionReplicationPosition( $lb2 ); $cpIndex = null; $cp->shutdown( null, 'sync', $cpIndex ); diff --git a/tests/phpunit/includes/db/LoadBalancerTest.php b/tests/phpunit/includes/db/LoadBalancerTest.php index 4291bccd80..defa0aa765 100644 --- a/tests/phpunit/includes/db/LoadBalancerTest.php +++ b/tests/phpunit/includes/db/LoadBalancerTest.php @@ -26,9 +26,11 @@ use Wikimedia\Rdbms\DatabaseDomain; use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\Rdbms\LoadMonitorNull; +use Wikimedia\TestingAccessWrapper; /** * @group Database + * @group medium * @covers \Wikimedia\Rdbms\LoadBalancer */ class LoadBalancerTest extends MediaWikiTestCase { @@ -110,7 +112,11 @@ class LoadBalancerTest extends MediaWikiTestCase { global $wgDBserver; // Simulate web request with DBO_TRX - $lb = $this->newMultiServerLocalLoadBalancer( DBO_TRX ); + $lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_TRX ] ); + + $this->assertEquals( 8, $lb->getServerCount() ); + $this->assertTrue( $lb->hasReplicaServers() ); + $this->assertTrue( $lb->hasStreamingReplicaServers() ); $dbw = $lb->getConnection( DB_MASTER ); $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); @@ -161,11 +167,12 @@ class LoadBalancerTest extends MediaWikiTestCase { ] ); } - private function newMultiServerLocalLoadBalancer( $flags = DBO_DEFAULT ) { + private function newMultiServerLocalLoadBalancer( $lbExtra = [], $srvExtra = [] ) { global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; $servers = [ - [ // master + // Master DB + 0 => $srvExtra + [ 'host' => $wgDBserver, 'dbname' => $wgDBname, 'tablePrefix' => $this->dbPrefix(), @@ -174,9 +181,19 @@ class LoadBalancerTest extends MediaWikiTestCase { 'type' => $wgDBtype, 'dbDirectory' => $wgSQLiteDataDir, 'load' => 0, - 'flags' => $flags ], - [ // emulated replica + // Main replica DBs + 1 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 100, + ], + 2 => $srvExtra + [ 'host' => $wgDBserver, 'dbname' => $wgDBname, 'tablePrefix' => $this->dbPrefix(), @@ -185,11 +202,81 @@ class LoadBalancerTest extends MediaWikiTestCase { 'type' => $wgDBtype, 'dbDirectory' => $wgSQLiteDataDir, 'load' => 100, - 'flags' => $flags + ], + // RC replica DBs + 3 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'recentchanges' => 100, + 'watchlist' => 100 + ], + ], + // Logging replica DBs + 4 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'logging' => 100 + ], + ], + 5 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'logging' => 100 + ], + ], + // Maintenance query replica DBs + 6 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'vslow' => 100 + ], + ], + // Replica DB that only has a copy of some static tables + 7 => $srvExtra + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'groupLoads' => [ + 'archive' => 100 + ], + 'is static' => true ] ]; - return new LoadBalancer( [ + return new LoadBalancer( $lbExtra + [ 'servers' => $servers, 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ), 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ), @@ -336,8 +423,7 @@ class LoadBalancerTest extends MediaWikiTestCase { $this->fail( "No exception thrown." ); } catch ( DBUnexpectedError $e ) { $this->assertEquals( - 'Wikimedia\Rdbms\LoadBalancer::openConnection: ' . - 'CONN_TRX_AUTOCOMMIT handle has a transaction.', + 'Handle requested with CONN_TRX_AUTOCOMMIT yet it has a transaction', $e->getMessage() ); } @@ -489,4 +575,47 @@ class LoadBalancerTest extends MediaWikiTestCase { $rConn->insert( 'test', [ 't' => 1 ], __METHOD__ ); } + + public function testQueryGroupIndex() { + $lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => false ] ); + /** @var LoadBalancer $lbWrapper */ + $lbWrapper = TestingAccessWrapper::newFromObject( $lb ); + + $rGeneric = $lb->getConnectionRef( DB_REPLICA ); + $mainIndexPicked = $rGeneric->getLBInfo( 'serverIndex' ); + + $this->assertEquals( $mainIndexPicked, $lbWrapper->getExistingReaderIndex( false ) ); + $this->assertTrue( in_array( $mainIndexPicked, [ 1, 2 ] ) ); + for ( $i = 0; $i < 300; ++$i ) { + $rLog = $lb->getConnectionRef( DB_REPLICA, [] ); + $this->assertEquals( + $mainIndexPicked, + $rLog->getLBInfo( 'serverIndex' ), + "Main index unchanged" ); + } + + $rRC = $lb->getConnectionRef( DB_REPLICA, [ 'recentchanges' ] ); + $rWL = $lb->getConnectionRef( DB_REPLICA, [ 'watchlist' ] ); + + $this->assertEquals( 3, $rRC->getLBInfo( 'serverIndex' ) ); + $this->assertEquals( 3, $rWL->getLBInfo( 'serverIndex' ) ); + + $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] ); + $logIndexPicked = $rLog->getLBInfo( 'serverIndex' ); + + $this->assertEquals( $logIndexPicked, $lbWrapper->getExistingReaderIndex( 'logging' ) ); + $this->assertTrue( in_array( $logIndexPicked, [ 4, 5 ] ) ); + + for ( $i = 0; $i < 300; ++$i ) { + $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] ); + $this->assertEquals( + $logIndexPicked, $rLog->getLBInfo( 'serverIndex' ), "Index unchanged" ); + } + + $rVslow = $lb->getConnectionRef( DB_REPLICA, [ 'vslow', 'logging' ] ); + $vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' ); + + $this->assertEquals( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) ); + $this->assertEquals( 6, $vslowIndexPicked ); + } } diff --git a/tests/phpunit/includes/debug/DeprecationHelperTest.php b/tests/phpunit/includes/debug/DeprecationHelperTest.php index b14d89c3f4..25dedbc2b0 100644 --- a/tests/phpunit/includes/debug/DeprecationHelperTest.php +++ b/tests/phpunit/includes/debug/DeprecationHelperTest.php @@ -37,10 +37,8 @@ class DeprecationHelperTest extends MediaWikiTestCase { public function provideGet() { return [ - [ 'protectedDeprecated', null, null ], [ 'protectedNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated' ], - [ 'privateDeprecated', null, null ], [ 'privateNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated' ], [ 'nonExistent', E_USER_NOTICE, 'Undefined property: TestDeprecatedClass::$nonExistent' ], @@ -71,10 +69,8 @@ class DeprecationHelperTest extends MediaWikiTestCase { public function provideSet() { return [ - [ 'protectedDeprecated', null, null ], [ 'protectedNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated' ], - [ 'privateDeprecated', null, null ], [ 'privateNonDeprecated', E_USER_ERROR, 'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated' ], [ 'nonExistent', null, null ], @@ -100,15 +96,6 @@ class DeprecationHelperTest extends MediaWikiTestCase { } public function testSubclassGetSet() { - $this->assertDeprecationWarningIssued( function () { - $this->assertSame( 1, $this->testSubclass->getDeprecatedPrivateParentProperty() ); - } ); - $this->assertDeprecationWarningIssued( function () { - $this->testSubclass->setDeprecatedPrivateParentProperty( 0 ); - } ); - $wrapper = TestingAccessWrapper::newFromObject( $this->testSubclass ); - $this->assertSame( 0, $wrapper->privateDeprecated ); - $fullName = 'TestDeprecatedClass::$privateNonDeprecated'; $this->assertErrorTriggered( function () { $this->assertSame( null, $this->testSubclass->getNonDeprecatedPrivateParentProperty() ); @@ -165,4 +152,22 @@ class DeprecationHelperTest extends MediaWikiTestCase { $this->assertNotEmpty( $wrapper->deprecationWarnings ); } + /** + * Test bad MW version values to throw exceptions as expected + * + * @dataProvider provideBadMWVersion + */ + public function testBadMWVersion( $version, $expected ) { + $this->setExpectedException( $expected ); + + wfDeprecated( __METHOD__, $version ); + } + + public function provideBadMWVersion() { + return [ + [ 1, Exception::class ], + [ 1.33, Exception::class ], + [ null, Exception::class ] + ]; + } } diff --git a/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php b/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php index 37a28c36ca..6c2fddaebf 100644 --- a/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php +++ b/tests/phpunit/includes/debug/logger/LegacyLoggerTest.php @@ -124,6 +124,26 @@ class LegacyLoggerTest extends MediaWikiTestCase { ]; } + /** + * @covers MediaWiki\Logger\LegacyLogger::interpolate + */ + public function testInterpolate_Error() { + // @todo Merge this into provideInterpolate once we drop HHVM support + if ( !class_exists( \Error::class ) ) { + $this->markTestSkipped( 'Error class does not exist' ); + } + + $err = new \Error( 'Test error' ); + $message = '{exception}'; + $context = [ 'exception' => $err ]; + $expect = '[Error ' . get_class( $err ) . '( ' . + $err->getFile() . ':' . $err->getLine() . ') ' . + $err->getMessage() . ']'; + + $this->assertEquals( + $expect, LegacyLogger::interpolate( $message, $context ) ); + } + /** * @covers MediaWiki\Logger\LegacyLogger::shouldEmit * @dataProvider provideShouldEmit diff --git a/tests/phpunit/includes/debug/logger/MonologSpiTest.php b/tests/phpunit/includes/debug/logger/MonologSpiTest.php deleted file mode 100644 index fda3ac614a..0000000000 --- a/tests/phpunit/includes/debug/logger/MonologSpiTest.php +++ /dev/null @@ -1,136 +0,0 @@ - [ - '@default' => [ - 'processors' => [ 'constructor' ], - 'handlers' => [ 'constructor' ], - ], - ], - 'processors' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - ], - 'handlers' => [ - 'constructor' => [ - 'class' => 'constructor', - 'formatter' => 'constructor', - ], - ], - 'formatters' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - ], - ]; - - $fixture = new MonologSpi( $base ); - $this->assertSame( - $base, - TestingAccessWrapper::newFromObject( $fixture )->config - ); - - $fixture->mergeConfig( [ - 'loggers' => [ - 'merged' => [ - 'processors' => [ 'merged' ], - 'handlers' => [ 'merged' ], - ], - ], - 'processors' => [ - 'merged' => [ - 'class' => 'merged', - ], - ], - 'magic' => [ - 'idkfa' => [ 'xyzzy' ], - ], - 'handlers' => [ - 'merged' => [ - 'class' => 'merged', - 'formatter' => 'merged', - ], - ], - 'formatters' => [ - 'merged' => [ - 'class' => 'merged', - ], - ], - ] ); - $this->assertSame( - [ - 'loggers' => [ - '@default' => [ - 'processors' => [ 'constructor' ], - 'handlers' => [ 'constructor' ], - ], - 'merged' => [ - 'processors' => [ 'merged' ], - 'handlers' => [ 'merged' ], - ], - ], - 'processors' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - ], - ], - 'handlers' => [ - 'constructor' => [ - 'class' => 'constructor', - 'formatter' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - 'formatter' => 'merged', - ], - ], - 'formatters' => [ - 'constructor' => [ - 'class' => 'constructor', - ], - 'merged' => [ - 'class' => 'merged', - ], - ], - 'magic' => [ - 'idkfa' => [ 'xyzzy' ], - ], - ], - TestingAccessWrapper::newFromObject( $fixture )->config - ); - } - -} diff --git a/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php deleted file mode 100644 index baa4df7390..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php +++ /dev/null @@ -1,76 +0,0 @@ -markTestSkipped( 'Avro is required for the AvroFormatterTest' ); - } - parent::setUp(); - } - - public function testSchemaNotAvailable() { - $formatter = new AvroFormatter( [] ); - $this->setExpectedException( - 'PHPUnit_Framework_Error_Notice', - "The schema for channel 'marty' is not available" - ); - $formatter->format( [ 'channel' => 'marty' ] ); - } - - public function testSchemaNotAvailableReturnValue() { - $formatter = new AvroFormatter( [] ); - $noticeEnabled = PHPUnit_Framework_Error_Notice::$enabled; - // disable conversion of notices - PHPUnit_Framework_Error_Notice::$enabled = false; - // have to keep the user notice from being output - \Wikimedia\suppressWarnings(); - $res = $formatter->format( [ 'channel' => 'marty' ] ); - \Wikimedia\restoreWarnings(); - PHPUnit_Framework_Error_Notice::$enabled = $noticeEnabled; - $this->assertNull( $res ); - } - - public function testDoesSomethingWhenSchemaAvailable() { - $formatter = new AvroFormatter( [ - 'string' => [ - 'schema' => [ 'type' => 'string' ], - 'revision' => 1010101, - ] - ] ); - $res = $formatter->format( [ - 'channel' => 'string', - 'context' => 'better to be', - ] ); - $this->assertNotNull( $res ); - // basically just tell us if avro changes its string encoding, or if - // we completely fail to generate a log message. - $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php deleted file mode 100644 index 4c0ca04fef..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php +++ /dev/null @@ -1,227 +0,0 @@ -markTestSkipped( 'Monolog and Kafka are required for the KafkaHandlerTest' ); - } - - parent::setUp(); - } - - public function topicNamingProvider() { - return [ - [ [], 'monolog_foo' ], - [ [ 'alias' => [ 'foo' => 'bar' ] ], 'bar' ] - ]; - } - - /** - * @dataProvider topicNamingProvider - */ - public function testTopicNaming( $options, $expect ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->once() ) - ->method( 'setMessages' ) - ->with( $expect, $this->anything(), $this->anything() ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - } - - public function swallowsExceptionsWhenRequested() { - return [ - // defaults to false - [ [], true ], - // also try false explicitly - [ [ 'swallowExceptions' => false ], true ], - // turn it on - [ [ 'swallowExceptions' => true ], false ], - ]; - } - - /** - * @dataProvider swallowsExceptionsWhenRequested - */ - public function testGetAvailablePartitionsException( $options, $expectException ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->throwException( new \Kafka\Exception ) ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - if ( $expectException ) { - $this->setExpectedException( 'Kafka\Exception' ); - } - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - - if ( !$expectException ) { - $this->assertTrue( true, 'no exception was thrown' ); - } - } - - /** - * @dataProvider swallowsExceptionsWhenRequested - */ - public function testSendException( $options, $expectException ) { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->throwException( new \Kafka\Exception ) ); - - if ( $expectException ) { - $this->setExpectedException( 'Kafka\Exception' ); - } - - $handler = new KafkaHandler( $produce, $options ); - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - - if ( !$expectException ) { - $this->assertTrue( true, 'no exception was thrown' ); - } - } - - public function testHandlesNullFormatterResult() { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $mockMethod = $produce->expects( $this->exactly( 2 ) ) - ->method( 'setMessages' ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - // evil hax - $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher; - TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher = - new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ - [ $this->anything(), $this->anything(), [ 'words' ] ], - [ $this->anything(), $this->anything(), [ 'lines' ] ] - ] ); - - $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); - $formatter->expects( $this->any() ) - ->method( 'format' ) - ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); - - $handler = new KafkaHandler( $produce, [] ); - $handler->setFormatter( $formatter ); - for ( $i = 0; $i < 3; ++$i ) { - $handler->handle( [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ] ); - } - } - - public function testBatchHandlesNullFormatterResult() { - $produce = $this->getMockBuilder( 'Kafka\Produce' ) - ->disableOriginalConstructor() - ->getMock(); - $produce->expects( $this->any() ) - ->method( 'getAvailablePartitions' ) - ->will( $this->returnValue( [ 'A' ] ) ); - $produce->expects( $this->once() ) - ->method( 'setMessages' ) - ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] ); - $produce->expects( $this->any() ) - ->method( 'send' ) - ->will( $this->returnValue( true ) ); - - $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); - $formatter->expects( $this->any() ) - ->method( 'format' ) - ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); - - $handler = new KafkaHandler( $produce, [] ); - $handler->setFormatter( $formatter ); - $handler->handleBatch( [ - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - [ - 'channel' => 'foo', - 'level' => Logger::EMERGENCY, - 'extra' => [], - 'context' => [], - ], - ] ); - } -} diff --git a/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php deleted file mode 100644 index 2768d32939..0000000000 --- a/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php +++ /dev/null @@ -1,75 +0,0 @@ -markTestSkipped( 'This test requires monolog to be installed' ); - } - parent::setUp(); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionNoTrace() { - $fixture = new LineFormatter(); - $fixture->includeStacktraces( false ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new LogicException( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); - $this->assertNotContains( "\n #0", $out ); - } - - /** - * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException - */ - public function testNormalizeExceptionTrace() { - $fixture = new LineFormatter(); - $fixture->includeStacktraces( true ); - $fixture = TestingAccessWrapper::newFromObject( $fixture ); - $boom = new InvalidArgumentException( 'boom', 0, - new LengthException( 'too long', 0, - new LogicException( 'Spock wuz here' ) - ) - ); - $out = $fixture->normalizeException( $boom ); - $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); - $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); - $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); - $this->assertContains( "\n #0", $out ); - } -} diff --git a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php index 3662c26646..b377c639f9 100644 --- a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php +++ b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php @@ -8,8 +8,8 @@ class DeferredUpdatesTest extends MediaWikiTestCase { * @covers DeferredUpdates::addUpdate * @covers DeferredUpdates::push * @covers DeferredUpdates::doUpdates - * @covers DeferredUpdates::execute - * @covers DeferredUpdates::runUpdate + * @covers DeferredUpdates::handleUpdateQueue + * @covers DeferredUpdates::attemptUpdate */ public function testAddAndRun() { $update = $this->getMockBuilder( DeferrableUpdate::class ) @@ -92,7 +92,7 @@ class DeferredUpdatesTest extends MediaWikiTestCase { /** * @covers DeferredUpdates::doUpdates - * @covers DeferredUpdates::execute + * @covers DeferredUpdates::handleUpdateQueue * @covers DeferredUpdates::addUpdate */ public function testDoUpdatesWeb() { @@ -189,7 +189,7 @@ class DeferredUpdatesTest extends MediaWikiTestCase { /** * @covers DeferredUpdates::doUpdates - * @covers DeferredUpdates::execute + * @covers DeferredUpdates::handleUpdateQueue * @covers DeferredUpdates::addUpdate */ public function testDoUpdatesCLI() { @@ -263,7 +263,7 @@ class DeferredUpdatesTest extends MediaWikiTestCase { /** * @covers DeferredUpdates::doUpdates - * @covers DeferredUpdates::execute + * @covers DeferredUpdates::handleUpdateQueue * @covers DeferredUpdates::addUpdate */ public function testPresendAddOnPostsendRun() { @@ -295,7 +295,7 @@ class DeferredUpdatesTest extends MediaWikiTestCase { } /** - * @covers DeferredUpdates::runUpdate + * @covers DeferredUpdates::attemptUpdate */ public function testRunUpdateTransactionScope() { $this->setMwGlobals( 'wgCommandLineMode', false ); @@ -315,7 +315,7 @@ class DeferredUpdatesTest extends MediaWikiTestCase { } /** - * @covers DeferredUpdates::runUpdate + * @covers DeferredUpdates::attemptUpdate * @covers TransactionRoundDefiningUpdate::getOrigin */ public function testRunOuterScopeUpdate() { @@ -326,10 +326,10 @@ class DeferredUpdatesTest extends MediaWikiTestCase { $ran = 0; DeferredUpdates::addUpdate( new TransactionRoundDefiningUpdate( - function () use ( &$ran, $lbFactory ) { - $ran++; - $this->assertFalse( $lbFactory->hasTransactionRound(), 'No transaction' ); - } ) + function () use ( &$ran, $lbFactory ) { + $ran++; + $this->assertFalse( $lbFactory->hasTransactionRound(), 'No transaction' ); + } ) ); DeferredUpdates::doUpdates(); diff --git a/tests/phpunit/includes/deferred/SearchUpdateTest.php b/tests/phpunit/includes/deferred/SearchUpdateTest.php index 74a5e3c470..8faaeda0d2 100644 --- a/tests/phpunit/includes/deferred/SearchUpdateTest.php +++ b/tests/phpunit/includes/deferred/SearchUpdateTest.php @@ -76,9 +76,6 @@ class MockSearch extends SearchEngine { public static $title; public static $text; - public function __construct( $db ) { - } - public function update( $id, $title, $text ) { self::$id = $id; self::$title = $title; diff --git a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php deleted file mode 100644 index 8d94404cd4..0000000000 --- a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php +++ /dev/null @@ -1,134 +0,0 @@ -format( $input ); - $this->assertEquals( $expectedOutput, $output ); - } - - private function getMockDiff( $edits ) { - $diff = $this->getMockBuilder( Diff::class ) - ->disableOriginalConstructor() - ->getMock(); - $diff->expects( $this->any() ) - ->method( 'getEdits' ) - ->will( $this->returnValue( $edits ) ); - return $diff; - } - - private function getMockDiffOp( $type = null, $orig = [], $closing = [] ) { - $diffOp = $this->getMockBuilder( DiffOp::class ) - ->disableOriginalConstructor() - ->getMock(); - $diffOp->expects( $this->any() ) - ->method( 'getType' ) - ->will( $this->returnValue( $type ) ); - $diffOp->expects( $this->any() ) - ->method( 'getOrig' ) - ->will( $this->returnValue( $orig ) ); - if ( $type === 'change' ) { - $diffOp->expects( $this->any() ) - ->method( 'getClosing' ) - ->with( $this->isType( 'integer' ) ) - ->will( $this->returnCallback( function () { - return 'mockLine'; - } ) ); - } else { - $diffOp->expects( $this->any() ) - ->method( 'getClosing' ) - ->will( $this->returnValue( $closing ) ); - } - return $diffOp; - } - - public function provideTestFormat() { - $emptyArrayTestCases = [ - $this->getMockDiff( [] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'add' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'delete' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'change' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'copy' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'FOOBARBAZ' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'add', 'line' ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [], [ 'line' ] ) ] ), - $this->getMockDiff( [ $this->getMockDiffOp( 'copy', [], [ 'line' ] ) ] ), - ]; - - $otherTestCases = []; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1' ] ) ] ), - [ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1', 'a2' ] ) ] ), - [ - [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ], - [ 'action' => 'add', 'new' => 'a2', 'newline' => 2 ], - ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1' ] ) ] ), - [ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1', 'd2' ] ) ] ), - [ - [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ], - [ 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ], - ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( 'change', [ 'd1' ], [ 'a1' ] ) ] ), - [ [ - 'action' => 'change', - 'old' => 'd1', - 'new' => 'mockLine', - 'newline' => 1, 'oldline' => 1 - ] ], - ]; - $otherTestCases[] = [ - $this->getMockDiff( [ $this->getMockDiffOp( - 'change', - [ 'd1', 'd2' ], - [ 'a1', 'a2' ] - ) ] ), - [ - [ - 'action' => 'change', - 'old' => 'd1', - 'new' => 'mockLine', - 'newline' => 1, 'oldline' => 1 - ], - [ - 'action' => 'change', - 'old' => 'd2', - 'new' => 'mockLine', - 'newline' => 2, 'oldline' => 2 - ], - ], - ]; - - $testCases = []; - foreach ( $emptyArrayTestCases as $testCase ) { - $testCases[] = [ $testCase, [] ]; - } - foreach ( $otherTestCases as $testCase ) { - $testCases[] = [ $testCase[0], $testCase[1] ]; - } - return $testCases; - } - -} diff --git a/tests/phpunit/includes/diff/DiffOpTest.php b/tests/phpunit/includes/diff/DiffOpTest.php deleted file mode 100644 index 3026fad6bd..0000000000 --- a/tests/phpunit/includes/diff/DiffOpTest.php +++ /dev/null @@ -1,68 +0,0 @@ -type = 'foo'; - $this->assertEquals( 'foo', $obj->getType() ); - } - - /** - * @covers DiffOp::getOrig - */ - public function testGetOrig() { - $obj = new FakeDiffOp(); - $obj->orig = [ 'foo' ]; - $this->assertEquals( [ 'foo' ], $obj->getOrig() ); - } - - /** - * @covers DiffOp::getClosing - */ - public function testGetClosing() { - $obj = new FakeDiffOp(); - $obj->closing = [ 'foo' ]; - $this->assertEquals( [ 'foo' ], $obj->getClosing() ); - } - - /** - * @covers DiffOp::getClosing - */ - public function testGetClosingWithParameter() { - $obj = new FakeDiffOp(); - $obj->closing = [ 'foo', 'bar', 'baz' ]; - $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); - $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); - $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); - $this->assertEquals( null, $obj->getClosing( 3 ) ); - } - - /** - * @covers DiffOp::norig - */ - public function testNorig() { - $obj = new FakeDiffOp(); - $this->assertEquals( 0, $obj->norig() ); - $obj->orig = [ 'foo' ]; - $this->assertEquals( 1, $obj->norig() ); - } - - /** - * @covers DiffOp::nclosing - */ - public function testNclosing() { - $obj = new FakeDiffOp(); - $this->assertEquals( 0, $obj->nclosing() ); - $obj->closing = [ 'foo' ]; - $this->assertEquals( 1, $obj->nclosing() ); - } - -} diff --git a/tests/phpunit/includes/diff/DiffTest.php b/tests/phpunit/includes/diff/DiffTest.php deleted file mode 100644 index da6d7d9544..0000000000 --- a/tests/phpunit/includes/diff/DiffTest.php +++ /dev/null @@ -1,19 +0,0 @@ -edits = 'FooBarBaz'; - $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); - } - -} diff --git a/tests/phpunit/includes/diff/SlotDiffRendererTest.php b/tests/phpunit/includes/diff/SlotDiffRendererTest.php index 58b433f129..a03280ddb2 100644 --- a/tests/phpunit/includes/diff/SlotDiffRendererTest.php +++ b/tests/phpunit/includes/diff/SlotDiffRendererTest.php @@ -1,4 +1,5 @@ setStatsdDataFactory( new NullStatsdDataFactory() ); $slotDiffRenderer->setLanguage( Language::factory( 'en' ) ); - $slotDiffRenderer->setWikiDiff2MovedParagraphDetectionCutoff( 0 ); $slotDiffRenderer->setEngine( TextSlotDiffRenderer::ENGINE_PHP ); return $slotDiffRenderer; } diff --git a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php deleted file mode 100644 index 6606065660..0000000000 --- a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -getTrace(); - $hasObject = false; - $hasArray = false; - foreach ( $trace as $frame ) { - if ( !isset( $frame['args'] ) ) { - continue; - } - foreach ( $frame['args'] as $arg ) { - $hasObject = $hasObject || is_object( $arg ); - $hasArray = $hasArray || is_array( $arg ); - } - - if ( $hasObject && $hasArray ) { - break; - } - } - $this->assertTrue( $hasObject, - "The stacktrace must have a function having an object has parameter" ); - $this->assertTrue( $hasArray, - "The stacktrace must have a function having an array has parameter" ); - - # Now we redact the trace.. and make sure no function arguments are - # arrays or objects. - $redacted = MWExceptionHandler::getRedactedTrace( $e ); - - foreach ( $redacted as $frame ) { - if ( !isset( $frame['args'] ) ) { - continue; - } - foreach ( $frame['args'] as $arg ) { - $this->assertNotInternalType( 'array', $arg ); - $this->assertNotInternalType( 'object', $arg ); - } - } - - $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); - } - - /** - * Helper function for testExpandArgumentsInCall - * - * Pass it an object and an array, and something by reference :-) - * - * @throws Exception - */ - protected static function helperThrowAnException( $a, $b, &$c ) { - throw new Exception(); - } -} diff --git a/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php b/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php new file mode 100644 index 0000000000..80e836fa58 --- /dev/null +++ b/tests/phpunit/includes/externalstore/ExternalStoreAccessTest.php @@ -0,0 +1,100 @@ +assertEquals( false, $access->isReadOnly() ); + + /** @var ExternalStoreMemory $store */ + $store = $esFactory->getStore( 'memory' ); + $this->assertInstanceOf( ExternalStoreMemory::class, $store ); + + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + $lb->expects( $this->any() )->method( 'getReadOnlyReason' )->willReturn( 'Locked' ); + $lb->expects( $this->any() )->method( 'getServerInfo' )->willReturn( [] ); + + $lbFactory = $this->getMockBuilder( LBFactory::class ) + ->disableOriginalConstructor()->getMock(); + $lbFactory->expects( $this->any() )->method( 'getExternalLB' )->willReturn( $lb ); + + $this->setService( 'DBLoadBalancerFactory', $lbFactory ); + + $active = [ 'db', 'mwstore' ]; + $defaults = [ 'DB://clusterX' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + $this->assertEquals( true, $access->isReadOnly() ); + + $store->clear(); + } + + /** + * @covers ExternalStoreAccess::fetchFromURL + * @covers ExternalStoreAccess::fetchFromURLs + * @covers ExternalStoreAccess::insert + */ + public function testReadWrite() { + $active = [ 'memory' ]; // active store types + $defaults = [ 'memory://cluster1', 'memory://cluster2' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + + /** @var ExternalStoreMemory $storeLocal */ + $storeLocal = $esFactory->getStore( 'memory' ); + /** @var ExternalStoreMemory $storeOther */ + $storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeOther ); + + $v1 = wfRandomString(); + $v2 = wfRandomString(); + $v3 = wfRandomString(); + + $this->assertEquals( false, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url1 = 'memory://cluster1/1'; + $this->assertEquals( + $url1, + $esFactory->getStoreForUrl( 'memory://cluster1' ) + ->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 ) + ); + $this->assertEquals( + $v1, + $esFactory->getStoreForUrl( 'memory://cluster1/1' ) + ->fetchFromURL( 'memory://cluster1/1' ) + ); + $this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url2 = $access->insert( $v2 ); + $url3 = $access->insert( $v3, [ 'domain' => 'other' ] ); + $this->assertNotFalse( $url2 ); + $this->assertNotFalse( $url3 ); + // There is only one active store type + $this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) ); + $this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) ); + $this->assertEquals( false, $storeOther->fetchFromURL( $url2 ) ); + $this->assertEquals( false, $storeLocal->fetchFromURL( $url3 ) ); + + $res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] ); + $this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" ); + + $storeLocal->clear(); + $storeOther->clear(); + } +} diff --git a/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php index f762693864..e63ce59493 100644 --- a/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php +++ b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php @@ -2,15 +2,26 @@ /** * @covers ExternalStoreFactory + * @covers ExternalStoreAccess */ -class ExternalStoreFactoryTest extends PHPUnit\Framework\TestCase { +class ExternalStoreFactoryTest extends MediaWikiTestCase { use MediaWikiCoversValidator; - public function testExternalStoreFactory_noStores() { - $factory = new ExternalStoreFactory( [] ); - $this->assertFalse( $factory->getStoreObject( 'ForTesting' ) ); - $this->assertFalse( $factory->getStoreObject( 'foo' ) ); + /** + * @expectedException ExternalStoreException + */ + public function testExternalStoreFactory_noStores1() { + $factory = new ExternalStoreFactory( [], [], 'test-id' ); + $factory->getStore( 'ForTesting' ); + } + + /** + * @expectedException ExternalStoreException + */ + public function testExternalStoreFactory_noStores2() { + $factory = new ExternalStoreFactory( [], [], 'test-id' ); + $factory->getStore( 'foo' ); } public function provideStoreNames() { @@ -24,18 +35,108 @@ class ExternalStoreFactoryTest extends PHPUnit\Framework\TestCase { * @dataProvider provideStoreNames */ public function testExternalStoreFactory_someStore_protoMatch( $proto ) { - $factory = new ExternalStoreFactory( [ 'ForTesting' ] ); - $store = $factory->getStoreObject( $proto ); + $factory = new ExternalStoreFactory( [ 'ForTesting' ], [], 'test-id' ); + $store = $factory->getStore( $proto ); $this->assertInstanceOf( ExternalStoreForTesting::class, $store ); } /** * @dataProvider provideStoreNames + * @expectedException ExternalStoreException */ public function testExternalStoreFactory_someStore_noProtoMatch( $proto ) { - $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ] ); - $store = $factory->getStoreObject( $proto ); - $this->assertFalse( $store ); + $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ], [], 'test-id' ); + $factory->getStore( $proto ); + } + + /** + * @covers ExternalStoreFactory::getProtocols + * @covers ExternalStoreFactory::getWriteBaseUrls + * @covers ExternalStoreFactory::getStore + */ + public function testStoreFactoryBasic() { + $active = [ 'memory' ]; + $defaults = [ 'memory://cluster1', 'memory://cluster2' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + + $this->assertEquals( $active, $esFactory->getProtocols() ); + $this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() ); + + /** @var ExternalStoreMemory $store */ + $store = $esFactory->getStore( 'memory' ); + $this->assertInstanceOf( ExternalStoreMemory::class, $store ); + $this->assertEquals( false, $store->isReadOnly( 'cluster1' ) ); + $this->assertEquals( false, $store->isReadOnly( 'cluster2' ) ); + $this->assertEquals( true, $store->isReadOnly( 'clusterOld' ) ); + + $lb = $this->getMockBuilder( \Wikimedia\Rdbms\LoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + $lb->expects( $this->any() )->method( 'getReadOnlyReason' )->willReturn( 'Locked' ); + $lbFactory = $this->getMockBuilder( \Wikimedia\Rdbms\LBFactory::class ) + ->disableOriginalConstructor()->getMock(); + $lbFactory->expects( $this->any() )->method( 'getExternalLB' )->willReturn( $lb ); + + $this->setService( 'DBLoadBalancerFactory', $lbFactory ); + + $active = [ 'db', 'mwstore' ]; + $defaults = [ 'db://clusterX' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $this->assertEquals( $active, $esFactory->getProtocols() ); + $this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() ); + + $store->clear(); } + /** + * @covers ExternalStoreFactory::getStoreForUrl + * @covers ExternalStoreFactory::getStoreLocationFromUrl + */ + public function testStoreFactoryReadWrite() { + $active = [ 'memory' ]; // active store types + $defaults = [ 'memory://cluster1', 'memory://cluster2' ]; + $esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' ); + $access = new ExternalStoreAccess( $esFactory ); + + /** @var ExternalStoreMemory $storeLocal */ + $storeLocal = $esFactory->getStore( 'memory' ); + /** @var ExternalStoreMemory $storeOther */ + $storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal ); + $this->assertInstanceOf( ExternalStoreMemory::class, $storeOther ); + + $v1 = wfRandomString(); + $v2 = wfRandomString(); + $v3 = wfRandomString(); + + $this->assertEquals( false, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url1 = 'memory://cluster1/1'; + $this->assertEquals( + $url1, + $esFactory->getStoreForUrl( 'memory://cluster1' ) + ->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 ) + ); + $this->assertEquals( + $v1, + $esFactory->getStoreForUrl( 'memory://cluster1/1' ) + ->fetchFromURL( 'memory://cluster1/1' ) + ); + $this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) ); + + $url2 = $access->insert( $v2 ); + $url3 = $access->insert( $v3, [ 'domain' => 'other' ] ); + $this->assertNotFalse( $url2 ); + $this->assertNotFalse( $url3 ); + // There is only one active store type + $this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) ); + $this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) ); + $this->assertEquals( false, $storeOther->fetchFromURL( $url2 ) ); + $this->assertEquals( false, $storeLocal->fetchFromURL( $url3 ) ); + + $res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] ); + $this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" ); + + $storeLocal->clear(); + $storeOther->clear(); + } } diff --git a/tests/phpunit/includes/externalstore/ExternalStoreTest.php b/tests/phpunit/includes/externalstore/ExternalStoreTest.php index 7ca38749fa..60db27da37 100644 --- a/tests/phpunit/includes/externalstore/ExternalStoreTest.php +++ b/tests/phpunit/includes/externalstore/ExternalStoreTest.php @@ -8,7 +8,7 @@ class ExternalStoreTest extends MediaWikiTestCase { public function testExternalFetchFromURL_noExternalStores() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [] ) + new ExternalStoreFactory( [], [], 'test-id' ) ); $this->assertFalse( @@ -23,7 +23,7 @@ class ExternalStoreTest extends MediaWikiTestCase { public function testExternalFetchFromURL_someExternalStore() { $this->setService( 'ExternalStoreFactory', - new ExternalStoreFactory( [ 'ForTesting' ] ) + new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' ) ); $this->assertEquals( diff --git a/tests/phpunit/includes/filerepo/file/ForeignDBFileTest.php b/tests/phpunit/includes/filerepo/file/ForeignDBFileTest.php new file mode 100644 index 0000000000..3c92ecb293 --- /dev/null +++ b/tests/phpunit/includes/filerepo/file/ForeignDBFileTest.php @@ -0,0 +1,16 @@ +createMock( LocalRepo::class ); + + $file = ForeignDBFile::newFromTitle( $title, $repoMock ); + + $this->assertInstanceOf( ForeignDBFile::class, $file ); + } +} diff --git a/tests/phpunit/includes/http/HttpRequestFactoryTest.php b/tests/phpunit/includes/http/HttpRequestFactoryTest.php new file mode 100644 index 0000000000..7429dcc9dd --- /dev/null +++ b/tests/phpunit/includes/http/HttpRequestFactoryTest.php @@ -0,0 +1,119 @@ +getMockBuilder( HttpRequestFactory::class ) + ->setMethods( [ 'create' ] ) + ->getMock(); + + $factory->method( 'create' ) + ->willReturnCallback( + function ( $url, array $options = [], $caller = __METHOD__ ) + use ( $req, $expectedUrl, $expectedOptions ) + { + $this->assertSame( $url, $expectedUrl ); + + foreach ( $expectedOptions as $opt => $exp ) { + $this->assertArrayHasKey( $opt, $options ); + $this->assertSame( $exp, $options[$opt] ); + } + + return $req; + } + ); + + return $factory; + } + + /** + * @return MWHttpRequest + */ + private function newFakeRequest( $result ) { + $req = $this->getMockBuilder( MWHttpRequest::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getContent', 'execute' ] ) + ->getMock(); + + if ( $result instanceof Status ) { + $req->method( 'getContent' ) + ->willReturn( $result->getValue() ); + $req->method( 'execute' ) + ->willReturn( $result ); + } else { + $req->method( 'getContent' ) + ->willReturn( $result ); + $req->method( 'execute' ) + ->willReturn( Status::newGood( $result ) ); + } + + return $req; + } + + public function testCreate() { + $factory = $this->newFactory(); + $this->assertInstanceOf( 'MWHttpRequest', $factory->create( 'http://example.test' ) ); + } + + public function testGetUserAgent() { + $factory = $this->newFactory(); + $this->assertStringStartsWith( 'MediaWiki/', $factory->getUserAgent() ); + } + + public function testGet() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'GET' ] + ); + + $this->assertSame( __METHOD__, $factory->get( 'https://example.test' ) ); + } + + public function testPost() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'POST' ] + ); + + $this->assertSame( __METHOD__, $factory->post( 'https://example.test' ) ); + } + + public function testRequest() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'GET' ] + ); + + $this->assertSame( __METHOD__, $factory->request( 'GET', 'https://example.test' ) ); + } + + public function testRequest_failed() { + $status = Status::newFatal( 'testing' ); + $req = $this->newFakeRequest( $status ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'POST' ] + ); + + $this->assertNull( $factory->request( 'POST', 'https://example.test' ) ); + } + +} diff --git a/tests/phpunit/includes/http/HttpTest.php b/tests/phpunit/includes/http/HttpTest.php index a8c53d9112..09bcfc9adf 100644 --- a/tests/phpunit/includes/http/HttpTest.php +++ b/tests/phpunit/includes/http/HttpTest.php @@ -1,53 +1,11 @@ assertEquals( $expected, $ok, $msg ); - } - - public static function cookieDomains() { - return [ - [ false, "org" ], - [ false, ".org" ], - [ true, "wikipedia.org" ], - [ true, ".wikipedia.org" ], - [ false, "co.uk" ], - [ false, ".co.uk" ], - [ false, "gov.uk" ], - [ false, ".gov.uk" ], - [ true, "supermarket.uk" ], - [ false, "uk" ], - [ false, ".uk" ], - [ false, "127.0.0." ], - [ false, "127." ], - [ false, "127.0.0.1." ], - [ true, "127.0.0.1" ], - [ false, "333.0.0.1" ], - [ true, "example.com" ], - [ false, "example.com." ], - [ true, ".example.com" ], - - [ true, ".example.com", "www.example.com" ], - [ false, "example.com", "www.example.com" ], - [ true, "127.0.0.1", "127.0.0.1" ], - [ false, "127.0.0.1", "localhost" ], - ]; - } /** * Test Http::isValidURI() @@ -150,409 +108,4 @@ class HttpTest extends MediaWikiTestCase { ]; } - public static function provideRelativeRedirects() { - return [ - [ - 'location' => [ 'http://newsite/file.ext', '/newfile.ext' ], - 'final' => 'http://newsite/newfile.ext', - 'Relative file path Location: interpreted as full URL' - ], - [ - 'location' => [ 'https://oldsite/file.ext' ], - 'final' => 'https://oldsite/file.ext', - 'Location to the HTTPS version of the site' - ], - [ - 'location' => [ - '/anotherfile.ext', - 'http://anotherfile/hoster.ext', - 'https://anotherfile/hoster.ext' - ], - 'final' => 'https://anotherfile/hoster.ext', - 'Relative file path Location: should keep the latest host and scheme!' - ], - [ - 'location' => [ '/anotherfile.ext' ], - 'final' => 'http://oldsite/anotherfile.ext', - 'Relative Location without domain ' - ], - [ - 'location' => null, - 'final' => 'http://oldsite/file.ext', - 'No Location (no redirect) ' - ], - ]; - } - - /** - * Warning: - * - * These tests are for code that makes use of an artifact of how CURL - * handles header reporting on redirect pages, and will need to be - * rewritten when T31232 is taken care of (high-level handling of HTTP redirects). - * - * @dataProvider provideRelativeRedirects - * @covers MWHttpRequest::getFinalUrl - */ - public function testRelativeRedirections( $location, $final, $message = null ) { - $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext', [], __METHOD__ ); - // Forge a Location header - $h->setRespHeaders( 'location', $location ); - // Verify it correctly fixes the Location - $this->assertEquals( $final, $h->getFinalUrl(), $message ); - } - - /** - * Constant values are from PHP 5.3.28 using cURL 7.24.0 - * @see https://www.php.net/manual/en/curl.constants.php - * - * All constant values are present so that developers don’t need to remember - * to add them if added at a later date. The commented out constants were - * not found anywhere in the MediaWiki core code. - * - * Commented out constants that were not available in: - * HipHop VM 3.3.0 (rel) - * Compiler: heads/master-0-g08810d920dfff59e0774cf2d651f92f13a637175 - * Repo schema: 3214fc2c684a4520485f715ee45f33f2182324b1 - * Extension API: 20140829 - * - * Commented out constants that were removed in PHP 5.6.0 - */ - public function provideCurlConstants() { - return [ - [ 'CURLAUTH_ANY' ], - [ 'CURLAUTH_ANYSAFE' ], - [ 'CURLAUTH_BASIC' ], - [ 'CURLAUTH_DIGEST' ], - [ 'CURLAUTH_GSSNEGOTIATE' ], - [ 'CURLAUTH_NTLM' ], - // [ 'CURLCLOSEPOLICY_CALLBACK' ], // removed in PHP 5.6.0 - // [ 'CURLCLOSEPOLICY_LEAST_RECENTLY_USED' ], // removed in PHP 5.6.0 - // [ 'CURLCLOSEPOLICY_LEAST_TRAFFIC' ], // removed in PHP 5.6.0 - // [ 'CURLCLOSEPOLICY_OLDEST' ], // removed in PHP 5.6.0 - // [ 'CURLCLOSEPOLICY_SLOWEST' ], // removed in PHP 5.6.0 - [ 'CURLE_ABORTED_BY_CALLBACK' ], - [ 'CURLE_BAD_CALLING_ORDER' ], - [ 'CURLE_BAD_CONTENT_ENCODING' ], - [ 'CURLE_BAD_FUNCTION_ARGUMENT' ], - [ 'CURLE_BAD_PASSWORD_ENTERED' ], - [ 'CURLE_COULDNT_CONNECT' ], - [ 'CURLE_COULDNT_RESOLVE_HOST' ], - [ 'CURLE_COULDNT_RESOLVE_PROXY' ], - [ 'CURLE_FAILED_INIT' ], - [ 'CURLE_FILESIZE_EXCEEDED' ], - [ 'CURLE_FILE_COULDNT_READ_FILE' ], - [ 'CURLE_FTP_ACCESS_DENIED' ], - [ 'CURLE_FTP_BAD_DOWNLOAD_RESUME' ], - [ 'CURLE_FTP_CANT_GET_HOST' ], - [ 'CURLE_FTP_CANT_RECONNECT' ], - [ 'CURLE_FTP_COULDNT_GET_SIZE' ], - [ 'CURLE_FTP_COULDNT_RETR_FILE' ], - [ 'CURLE_FTP_COULDNT_SET_ASCII' ], - [ 'CURLE_FTP_COULDNT_SET_BINARY' ], - [ 'CURLE_FTP_COULDNT_STOR_FILE' ], - [ 'CURLE_FTP_COULDNT_USE_REST' ], - [ 'CURLE_FTP_PORT_FAILED' ], - [ 'CURLE_FTP_QUOTE_ERROR' ], - [ 'CURLE_FTP_SSL_FAILED' ], - [ 'CURLE_FTP_USER_PASSWORD_INCORRECT' ], - [ 'CURLE_FTP_WEIRD_227_FORMAT' ], - [ 'CURLE_FTP_WEIRD_PASS_REPLY' ], - [ 'CURLE_FTP_WEIRD_PASV_REPLY' ], - [ 'CURLE_FTP_WEIRD_SERVER_REPLY' ], - [ 'CURLE_FTP_WEIRD_USER_REPLY' ], - [ 'CURLE_FTP_WRITE_ERROR' ], - [ 'CURLE_FUNCTION_NOT_FOUND' ], - [ 'CURLE_GOT_NOTHING' ], - [ 'CURLE_HTTP_NOT_FOUND' ], - [ 'CURLE_HTTP_PORT_FAILED' ], - [ 'CURLE_HTTP_POST_ERROR' ], - [ 'CURLE_HTTP_RANGE_ERROR' ], - [ 'CURLE_LDAP_CANNOT_BIND' ], - [ 'CURLE_LDAP_INVALID_URL' ], - [ 'CURLE_LDAP_SEARCH_FAILED' ], - [ 'CURLE_LIBRARY_NOT_FOUND' ], - [ 'CURLE_MALFORMAT_USER' ], - [ 'CURLE_OBSOLETE' ], - [ 'CURLE_OK' ], - [ 'CURLE_OPERATION_TIMEOUTED' ], - [ 'CURLE_OUT_OF_MEMORY' ], - [ 'CURLE_PARTIAL_FILE' ], - [ 'CURLE_READ_ERROR' ], - [ 'CURLE_RECV_ERROR' ], - [ 'CURLE_SEND_ERROR' ], - [ 'CURLE_SHARE_IN_USE' ], - // [ 'CURLE_SSH' ], // not present in HHVM 3.3.0-dev - [ 'CURLE_SSL_CACERT' ], - [ 'CURLE_SSL_CERTPROBLEM' ], - [ 'CURLE_SSL_CIPHER' ], - [ 'CURLE_SSL_CONNECT_ERROR' ], - [ 'CURLE_SSL_ENGINE_NOTFOUND' ], - [ 'CURLE_SSL_ENGINE_SETFAILED' ], - [ 'CURLE_SSL_PEER_CERTIFICATE' ], - [ 'CURLE_TELNET_OPTION_SYNTAX' ], - [ 'CURLE_TOO_MANY_REDIRECTS' ], - [ 'CURLE_UNKNOWN_TELNET_OPTION' ], - [ 'CURLE_UNSUPPORTED_PROTOCOL' ], - [ 'CURLE_URL_MALFORMAT' ], - [ 'CURLE_URL_MALFORMAT_USER' ], - [ 'CURLE_WRITE_ERROR' ], - [ 'CURLFTPAUTH_DEFAULT' ], - [ 'CURLFTPAUTH_SSL' ], - [ 'CURLFTPAUTH_TLS' ], - // [ 'CURLFTPMETHOD_MULTICWD' ], // not present in HHVM 3.3.0-dev - // [ 'CURLFTPMETHOD_NOCWD' ], // not present in HHVM 3.3.0-dev - // [ 'CURLFTPMETHOD_SINGLECWD' ], // not present in HHVM 3.3.0-dev - [ 'CURLFTPSSL_ALL' ], - [ 'CURLFTPSSL_CONTROL' ], - [ 'CURLFTPSSL_NONE' ], - [ 'CURLFTPSSL_TRY' ], - // [ 'CURLINFO_CERTINFO' ], // not present in HHVM 3.3.0-dev - [ 'CURLINFO_CONNECT_TIME' ], - [ 'CURLINFO_CONTENT_LENGTH_DOWNLOAD' ], - [ 'CURLINFO_CONTENT_LENGTH_UPLOAD' ], - [ 'CURLINFO_CONTENT_TYPE' ], - [ 'CURLINFO_EFFECTIVE_URL' ], - [ 'CURLINFO_FILETIME' ], - [ 'CURLINFO_HEADER_OUT' ], - [ 'CURLINFO_HEADER_SIZE' ], - [ 'CURLINFO_HTTP_CODE' ], - [ 'CURLINFO_NAMELOOKUP_TIME' ], - [ 'CURLINFO_PRETRANSFER_TIME' ], - [ 'CURLINFO_PRIVATE' ], - [ 'CURLINFO_REDIRECT_COUNT' ], - [ 'CURLINFO_REDIRECT_TIME' ], - // [ 'CURLINFO_REDIRECT_URL' ], // not present in HHVM 3.3.0-dev - [ 'CURLINFO_REQUEST_SIZE' ], - [ 'CURLINFO_SIZE_DOWNLOAD' ], - [ 'CURLINFO_SIZE_UPLOAD' ], - [ 'CURLINFO_SPEED_DOWNLOAD' ], - [ 'CURLINFO_SPEED_UPLOAD' ], - [ 'CURLINFO_SSL_VERIFYRESULT' ], - [ 'CURLINFO_STARTTRANSFER_TIME' ], - [ 'CURLINFO_TOTAL_TIME' ], - [ 'CURLMSG_DONE' ], - [ 'CURLM_BAD_EASY_HANDLE' ], - [ 'CURLM_BAD_HANDLE' ], - [ 'CURLM_CALL_MULTI_PERFORM' ], - [ 'CURLM_INTERNAL_ERROR' ], - [ 'CURLM_OK' ], - [ 'CURLM_OUT_OF_MEMORY' ], - [ 'CURLOPT_AUTOREFERER' ], - [ 'CURLOPT_BINARYTRANSFER' ], - [ 'CURLOPT_BUFFERSIZE' ], - [ 'CURLOPT_CAINFO' ], - [ 'CURLOPT_CAPATH' ], - // [ 'CURLOPT_CERTINFO' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_CLOSEPOLICY' ], // removed in PHP 5.6.0 - [ 'CURLOPT_CONNECTTIMEOUT' ], - [ 'CURLOPT_CONNECTTIMEOUT_MS' ], - [ 'CURLOPT_COOKIE' ], - [ 'CURLOPT_COOKIEFILE' ], - [ 'CURLOPT_COOKIEJAR' ], - [ 'CURLOPT_COOKIESESSION' ], - [ 'CURLOPT_CRLF' ], - [ 'CURLOPT_CUSTOMREQUEST' ], - [ 'CURLOPT_DNS_CACHE_TIMEOUT' ], - [ 'CURLOPT_DNS_USE_GLOBAL_CACHE' ], - [ 'CURLOPT_EGDSOCKET' ], - [ 'CURLOPT_ENCODING' ], - [ 'CURLOPT_FAILONERROR' ], - [ 'CURLOPT_FILE' ], - [ 'CURLOPT_FILETIME' ], - [ 'CURLOPT_FOLLOWLOCATION' ], - [ 'CURLOPT_FORBID_REUSE' ], - [ 'CURLOPT_FRESH_CONNECT' ], - [ 'CURLOPT_FTPAPPEND' ], - [ 'CURLOPT_FTPLISTONLY' ], - [ 'CURLOPT_FTPPORT' ], - [ 'CURLOPT_FTPSSLAUTH' ], - [ 'CURLOPT_FTP_CREATE_MISSING_DIRS' ], - // [ 'CURLOPT_FTP_FILEMETHOD' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_FTP_SKIP_PASV_IP' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_FTP_SSL' ], - [ 'CURLOPT_FTP_USE_EPRT' ], - [ 'CURLOPT_FTP_USE_EPSV' ], - [ 'CURLOPT_HEADER' ], - [ 'CURLOPT_HEADERFUNCTION' ], - [ 'CURLOPT_HTTP200ALIASES' ], - [ 'CURLOPT_HTTPAUTH' ], - [ 'CURLOPT_HTTPGET' ], - [ 'CURLOPT_HTTPHEADER' ], - [ 'CURLOPT_HTTPPROXYTUNNEL' ], - [ 'CURLOPT_HTTP_VERSION' ], - [ 'CURLOPT_INFILE' ], - [ 'CURLOPT_INFILESIZE' ], - [ 'CURLOPT_INTERFACE' ], - [ 'CURLOPT_IPRESOLVE' ], - // [ 'CURLOPT_KEYPASSWD' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_KRB4LEVEL' ], - [ 'CURLOPT_LOW_SPEED_LIMIT' ], - [ 'CURLOPT_LOW_SPEED_TIME' ], - [ 'CURLOPT_MAXCONNECTS' ], - [ 'CURLOPT_MAXREDIRS' ], - // [ 'CURLOPT_MAX_RECV_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_MAX_SEND_SPEED_LARGE' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_NETRC' ], - [ 'CURLOPT_NOBODY' ], - [ 'CURLOPT_NOPROGRESS' ], - [ 'CURLOPT_NOSIGNAL' ], - [ 'CURLOPT_PORT' ], - [ 'CURLOPT_POST' ], - [ 'CURLOPT_POSTFIELDS' ], - [ 'CURLOPT_POSTQUOTE' ], - [ 'CURLOPT_POSTREDIR' ], - [ 'CURLOPT_PRIVATE' ], - [ 'CURLOPT_PROGRESSFUNCTION' ], - // [ 'CURLOPT_PROTOCOLS' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_PROXY' ], - [ 'CURLOPT_PROXYAUTH' ], - [ 'CURLOPT_PROXYPORT' ], - [ 'CURLOPT_PROXYTYPE' ], - [ 'CURLOPT_PROXYUSERPWD' ], - [ 'CURLOPT_PUT' ], - [ 'CURLOPT_QUOTE' ], - [ 'CURLOPT_RANDOM_FILE' ], - [ 'CURLOPT_RANGE' ], - [ 'CURLOPT_READDATA' ], - [ 'CURLOPT_READFUNCTION' ], - // [ 'CURLOPT_REDIR_PROTOCOLS' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_REFERER' ], - [ 'CURLOPT_RESUME_FROM' ], - [ 'CURLOPT_RETURNTRANSFER' ], - // [ 'CURLOPT_SSH_AUTH_TYPES' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_SSH_HOST_PUBLIC_KEY_MD5' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_SSH_PRIVATE_KEYFILE' ], // not present in HHVM 3.3.0-dev - // [ 'CURLOPT_SSH_PUBLIC_KEYFILE' ], // not present in HHVM 3.3.0-dev - [ 'CURLOPT_SSLCERT' ], - [ 'CURLOPT_SSLCERTPASSWD' ], - [ 'CURLOPT_SSLCERTTYPE' ], - [ 'CURLOPT_SSLENGINE' ], - [ 'CURLOPT_SSLENGINE_DEFAULT' ], - [ 'CURLOPT_SSLKEY' ], - [ 'CURLOPT_SSLKEYPASSWD' ], - [ 'CURLOPT_SSLKEYTYPE' ], - [ 'CURLOPT_SSLVERSION' ], - [ 'CURLOPT_SSL_CIPHER_LIST' ], - [ 'CURLOPT_SSL_VERIFYHOST' ], - [ 'CURLOPT_SSL_VERIFYPEER' ], - [ 'CURLOPT_STDERR' ], - [ 'CURLOPT_TCP_NODELAY' ], - [ 'CURLOPT_TIMECONDITION' ], - [ 'CURLOPT_TIMEOUT' ], - [ 'CURLOPT_TIMEOUT_MS' ], - [ 'CURLOPT_TIMEVALUE' ], - [ 'CURLOPT_TRANSFERTEXT' ], - [ 'CURLOPT_UNRESTRICTED_AUTH' ], - [ 'CURLOPT_UPLOAD' ], - [ 'CURLOPT_URL' ], - [ 'CURLOPT_USERAGENT' ], - [ 'CURLOPT_USERPWD' ], - [ 'CURLOPT_VERBOSE' ], - [ 'CURLOPT_WRITEFUNCTION' ], - [ 'CURLOPT_WRITEHEADER' ], - // [ 'CURLPROTO_ALL' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_DICT' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_FILE' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_FTP' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_FTPS' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_HTTP' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_HTTPS' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_LDAP' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_LDAPS' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_SCP' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_SFTP' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_TELNET' ], // not present in HHVM 3.3.0-dev - // [ 'CURLPROTO_TFTP' ], // not present in HHVM 3.3.0-dev - [ 'CURLPROXY_HTTP' ], - // [ 'CURLPROXY_SOCKS4' ], // not present in HHVM 3.3.0-dev - [ 'CURLPROXY_SOCKS5' ], - // [ 'CURLSSH_AUTH_DEFAULT' ], // not present in HHVM 3.3.0-dev - // [ 'CURLSSH_AUTH_HOST' ], // not present in HHVM 3.3.0-dev - // [ 'CURLSSH_AUTH_KEYBOARD' ], // not present in HHVM 3.3.0-dev - // [ 'CURLSSH_AUTH_NONE' ], // not present in HHVM 3.3.0-dev - // [ 'CURLSSH_AUTH_PASSWORD' ], // not present in HHVM 3.3.0-dev - // [ 'CURLSSH_AUTH_PUBLICKEY' ], // not present in HHVM 3.3.0-dev - [ 'CURLVERSION_NOW' ], - [ 'CURL_HTTP_VERSION_1_0' ], - [ 'CURL_HTTP_VERSION_1_1' ], - [ 'CURL_HTTP_VERSION_NONE' ], - [ 'CURL_IPRESOLVE_V4' ], - [ 'CURL_IPRESOLVE_V6' ], - [ 'CURL_IPRESOLVE_WHATEVER' ], - [ 'CURL_NETRC_IGNORED' ], - [ 'CURL_NETRC_OPTIONAL' ], - [ 'CURL_NETRC_REQUIRED' ], - [ 'CURL_TIMECOND_IFMODSINCE' ], - [ 'CURL_TIMECOND_IFUNMODSINCE' ], - [ 'CURL_TIMECOND_LASTMOD' ], - [ 'CURL_VERSION_IPV6' ], - [ 'CURL_VERSION_KERBEROS4' ], - [ 'CURL_VERSION_LIBZ' ], - [ 'CURL_VERSION_SSL' ], - ]; - } - - /** - * Added this test based on an issue experienced with HHVM 3.3.0-dev - * where it did not define a cURL constant. T72570 - * - * @dataProvider provideCurlConstants - * @coversNothing - */ - public function testCurlConstants( $value ) { - $this->checkPHPExtension( 'curl' ); - - $this->assertTrue( defined( $value ), $value . ' not defined' ); - } -} - -/** - * Class to let us overwrite MWHttpRequest respHeaders variable - */ -class MWHttpRequestTester extends MWHttpRequest { - // function derived from the MWHttpRequest factory function but - // returns appropriate tester class here - public static function factory( $url, array $options = null, $caller = __METHOD__ ) { - if ( !Http::$httpEngine ) { - Http::$httpEngine = 'guzzle'; - } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { - throw new DomainException( __METHOD__ . ': curl (https://www.php.net/curl) is not ' . - 'installed, but Http::$httpEngine is set to "curl"' ); - } - - switch ( Http::$httpEngine ) { - case 'guzzle': - return new GuzzleHttpRequestTester( $url, $options, $caller ); - case 'curl': - return new CurlHttpRequestTester( $url, $options, $caller ); - case 'php': - if ( !wfIniGetBool( 'allow_url_fopen' ) ) { - throw new DomainException( __METHOD__ . - ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' - . 'If possible, curl should be used instead. See https://www.php.net/curl.' ); - } - - return new PhpHttpRequestTester( $url, $options, $caller ); - default: - } - } -} - -class GuzzleHttpRequestTester extends GuzzleHttpRequest { - function setRespHeaders( $name, $value ) { - $this->respHeaders[$name] = $value; - } -} - -class CurlHttpRequestTester extends CurlHttpRequest { - function setRespHeaders( $name, $value ) { - $this->respHeaders[$name] = $value; - } -} - -class PhpHttpRequestTester extends PhpHttpRequest { - function setRespHeaders( $name, $value ) { - $this->respHeaders[$name] = $value; - } } diff --git a/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php index 1db2215ecb..7d1d1a2f13 100644 --- a/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php +++ b/tests/phpunit/includes/import/ImportLinkCacheIntegrationTest.php @@ -1,4 +1,5 @@ getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $importer = new WikiImporter( $source, @@ -81,7 +78,7 @@ EOF * @param string|null $redirectTitle */ public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { - $source = $this->getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $redirect = null; $callback = function ( Title $title, ForeignTitle $foreignTitle, $revCount, @@ -167,7 +164,7 @@ EOF * @param array|null $namespaces */ public function testSiteInfoContainsNamespaces( $xml, $namespaces ) { - $source = $this->getDataSource( $xml ); + $source = new ImportStringSource( $xml ); $importNamespaces = null; $callback = function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) { @@ -252,7 +249,7 @@ EOF $n = ( $assign ? 1 : 0 ) + ( $create ? 2 : 0 ); // phpcs:disable Generic.Files.LineLength - $source = $this->getDataSource( << TestImportPage diff --git a/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/includes/installer/InstallDocFormatterTest.php deleted file mode 100644 index 9584d4b8c4..0000000000 --- a/tests/phpunit/includes/installer/InstallDocFormatterTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertEquals( - $expected, - InstallDocFormatter::format( $unformattedText ), - $message - ); - } - - /** - * Provider for testFormat() - */ - public static function provideDocFormattingTests() { - # Format: (expected string, unformattedText string, optional message) - return [ - # Escape some wikitext - [ 'Install <tag>', 'Install ', 'Escaping <' ], - [ 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ], - [ 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ], - [ 'Install __TOC__', 'Install __TOC__', 'Escaping __' ], - [ 'Install ', "Install \r", 'Removing \r' ], - - # Transform \t{1,2} into :{1,2} - [ ':One indentation', "\tOne indentation", 'Replacing a single \t' ], - [ '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ], - - # Transform 'T123' links - [ - '[https://phabricator.wikimedia.org/T123 T123]', - 'T123', 'Testing T123 links' ], - [ - 'bug [https://phabricator.wikimedia.org/T123 T123]', - 'bug T123', 'Testing bug T123 links' ], - [ - '([https://phabricator.wikimedia.org/T987654 T987654])', - '(T987654)', 'Testing (T987654) links' ], - - # "Tabc" shouldn't work - [ 'Tfoobar', 'Tfoobar', "Don't match T followed by non-digits" ], - [ 'T!!fakefake!!', 'T!!fakefake!!', "Don't match T followed by non-digits" ], - - # Transform 'bug 123' links - [ - '[https://bugzilla.wikimedia.org/123 bug 123]', - 'bug 123', 'Testing bug 123 links' ], - [ - '([https://bugzilla.wikimedia.org/987654 bug 987654])', - '(bug 987654)', 'Testing (bug 987654) links' ], - - # "bug abc" shouldn't work - [ 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ], - [ 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ], - - # Transform '$wgFooBar' links - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]', - '$wgFooBar', 'Testing basic $wgFooBar' ], - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]', - '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ], - [ - '' - . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]', - '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ], - - # Icky variables that shouldn't link - [ - '$myAwesomeVariable', - '$myAwesomeVariable', - 'Testing $myAwesomeVariable (not starting with $wg)' - ], - [ '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ], - ]; - } -} diff --git a/tests/phpunit/includes/installer/OracleInstallerTest.php b/tests/phpunit/includes/installer/OracleInstallerTest.php deleted file mode 100644 index e255089de4..0000000000 --- a/tests/phpunit/includes/installer/OracleInstallerTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertEquals( $expected, - OracleInstaller::checkConnectStringFormat( $connectString ), - $msg - ); - } - - /** - * Provider to test OracleInstaller::checkConnectStringFormat() - */ - function provideOracleConnectStrings() { - // expected result, connectString[, message] - return [ - [ true, 'simple_01', 'Simple TNS name' ], - [ true, 'simple_01.world', 'TNS name with domain' ], - [ true, 'simple_01.domain.net', 'TNS name with domain' ], - [ true, 'host123', 'Host only' ], - [ true, 'host123.domain.net', 'FQDN only' ], - [ true, '//host123.domain.net', 'FQDN URL only' ], - [ true, '123.223.213.132', 'Host IP only' ], - [ true, 'host:1521', 'Host and port' ], - [ true, 'host:1521/service', 'Host, port and service' ], - [ true, 'host:1521/service:shared', 'Host, port, service and shared server type' ], - [ true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ], - [ true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ], - [ - true, - 'host:1521/service:shared/instance1', - 'Host, port, service, server type and instance' - ], - [ true, 'host:1521//instance1', 'Host, port and instance' ], - ]; - } - -} diff --git a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php deleted file mode 100644 index 0a13de1d96..0000000000 --- a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php +++ /dev/null @@ -1,133 +0,0 @@ -interwikiLookup = new InterwikiLookupAdapter( - $this->getSiteLookup( $this->getSites() ) - ); - } - - public function testIsValidInterwiki() { - $this->assertTrue( - $this->interwikiLookup->isValidInterwiki( 'enwt' ), - 'enwt known prefix is valid' - ); - $this->assertTrue( - $this->interwikiLookup->isValidInterwiki( 'foo' ), - 'foo site known prefix is valid' - ); - $this->assertFalse( - $this->interwikiLookup->isValidInterwiki( 'xyz' ), - 'unknown prefix is not valid' - ); - } - - public function testFetch() { - $interwiki = $this->interwikiLookup->fetch( '' ); - $this->assertNull( $interwiki ); - - $interwiki = $this->interwikiLookup->fetch( 'xyz' ); - $this->assertFalse( $interwiki ); - - $interwiki = $this->interwikiLookup->fetch( 'foo' ); - $this->assertInstanceOf( Interwiki::class, $interwiki ); - $this->assertSame( 'foobar', $interwiki->getWikiID() ); - - $interwiki = $this->interwikiLookup->fetch( 'enwt' ); - $this->assertInstanceOf( Interwiki::class, $interwiki ); - - $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' ); - $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); - $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' ); - $this->assertTrue( $interwiki->isLocal(), 'isLocal' ); - } - - public function testGetAllPrefixes() { - $foo = [ - 'iw_prefix' => 'foo', - 'iw_url' => '', - 'iw_api' => '', - 'iw_wikiid' => 'foobar', - 'iw_local' => false, - 'iw_trans' => false, - ]; - $enwt = [ - 'iw_prefix' => 'enwt', - 'iw_url' => 'https://en.wiktionary.org/wiki/$1', - 'iw_api' => 'https://en.wiktionary.org/w/api.php', - 'iw_wikiid' => 'enwiktionary', - 'iw_local' => true, - 'iw_trans' => false, - ]; - - $this->assertEquals( - [ $foo, $enwt ], - $this->interwikiLookup->getAllPrefixes(), - 'getAllPrefixes()' - ); - - $this->assertEquals( - [ $foo ], - $this->interwikiLookup->getAllPrefixes( false ), - 'get external prefixes' - ); - - $this->assertEquals( - [ $enwt ], - $this->interwikiLookup->getAllPrefixes( true ), - 'get local prefixes' - ); - } - - private function getSiteLookup( SiteList $sites ) { - $siteLookup = $this->getMockBuilder( SiteLookup::class ) - ->disableOriginalConstructor() - ->getMock(); - - $siteLookup->expects( $this->any() ) - ->method( 'getSites' ) - ->will( $this->returnValue( $sites ) ); - - return $siteLookup; - } - - private function getSites() { - $sites = []; - - $site = new Site(); - $site->setGlobalId( 'foobar' ); - $site->addInterwikiId( 'foo' ); - $site->setSource( 'external' ); - $sites[] = $site; - - $site = new MediaWikiSite(); - $site->setGlobalId( 'enwiktionary' ); - $site->setGroup( 'wiktionary' ); - $site->setLanguageCode( 'en' ); - $site->addNavigationId( 'enwiktionary' ); - $site->addInterwikiId( 'enwt' ); - $site->setSource( 'local' ); - $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); - $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); - $sites[] = $site; - - return new SiteList( $sites ); - } - -} diff --git a/tests/phpunit/includes/interwiki/InterwikiTest.php b/tests/phpunit/includes/interwiki/InterwikiTest.php index 947be75eae..d2c9a327e0 100644 --- a/tests/phpunit/includes/interwiki/InterwikiTest.php +++ b/tests/phpunit/includes/interwiki/InterwikiTest.php @@ -1,4 +1,5 @@ new stdClass, - 'emptyArray' => [], - 'string' => 'foobar\\', - 'filledArray' => [ - [ - 123, - 456, - ], - // Nested json works without problems - '"7":["8",{"9":"10"}]', - // Whitespace clean up doesn't touch strings that look alike - "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", - ], - ]; - - // No trailing whitespace, no trailing linefeed - $json = '{ - "emptyObject": {}, - "emptyArray": [], - "string": "foobar\\\\", - "filledArray": [ - [ - 123, - 456 - ], - "\"7\":[\"8\",{\"9\":\"10\"}]", - "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" - ] -}'; - - $json = str_replace( "\r", '', $json ); // Windows compat - $json = str_replace( "\t", $expectedIndent, $json ); - $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); - } - - public static function provideEncodeDefault() { - return self::getEncodeTestCases( [] ); - } - - /** - * @dataProvider provideEncodeDefault - */ - public function testEncodeDefault( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from ) ); - } - - public static function provideEncodeUtf8() { - return self::getEncodeTestCases( [ 'unicode' ] ); - } - - /** - * @dataProvider provideEncodeUtf8 - */ - public function testEncodeUtf8( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); - } - - public static function provideEncodeXmlMeta() { - return self::getEncodeTestCases( [ 'xmlmeta' ] ); - } - - /** - * @dataProvider provideEncodeXmlMeta - */ - public function testEncodeXmlMeta( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); - } - - public static function provideEncodeAllOk() { - return self::getEncodeTestCases( [ 'unicode', 'xmlmeta' ] ); - } - - /** - * @dataProvider provideEncodeAllOk - */ - public function testEncodeAllOk( $from, $to ) { - $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); - } - - public function testEncodePhpBug46944() { - $this->assertNotEquals( - '\ud840\udc00', - strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), - 'Test encoding an broken json_encode character (U+20000)' - ); - } - - public function testEncodeFail() { - // Set up a recursive object that can't be encoded. - $a = new stdClass; - $b = new stdClass; - $a->b = $b; - $b->a = $a; - $this->assertFalse( FormatJson::encode( $a ) ); - } - - public function testDecodeReturnType() { - $this->assertInternalType( - 'object', - FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), - 'Default to object' - ); - - $this->assertInternalType( - 'array', - FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), - 'Optional array' - ); - } - - public static function provideParse() { - return [ - [ null ], - [ true ], - [ false ], - [ 0 ], - [ 1 ], - [ 1.2 ], - [ '' ], - [ 'str' ], - [ [ 0, 1, 2 ] ], - [ [ 'a' => 'b' ] ], - [ [ 'a' => 'b' ] ], - [ [ 'a' => 'b', 'x' => [ 'c' => 'd' ] ] ], - ]; - } - - /** - * Recursively convert arrays into stdClass - * @param array|string|bool|int|float|null $value - * @return stdClass|string|bool|int|float|null - */ - public static function toObject( $value ) { - return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value ); - } - - /** - * @dataProvider provideParse - * @param mixed $value - */ - public function testParse( $value ) { - $expected = self::toObject( $value ); - $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); - $this->assertJson( $json ); - - $st = FormatJson::parse( $json ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $expected, $st->getValue() ); - - $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $value, $st->getValue() ); - } - /** * Test data for testParseTryFixing. * @@ -252,185 +79,4 @@ class FormatJsonTest extends MediaWikiTestCase { } } - public static function provideParseErrors() { - return [ - [ 'aaa' ], - [ '{"j": 1 ] }' ], - ]; - } - - /** - * @dataProvider provideParseErrors - * @param mixed $value - */ - public function testParseErrors( $value ) { - $st = FormatJson::parse( $value ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertFalse( $st->isOK() ); - } - - public function provideStripComments() { - return [ - [ '{"a":"b"}', '{"a":"b"}' ], - [ "{\"a\":\"b\"}\n", "{\"a\":\"b\"}\n" ], - [ '/*c*/{"c":"b"}', '{"c":"b"}' ], - [ '{"a":"c"}/*c*/', '{"a":"c"}' ], - [ '/*c//d*/{"c":"b"}', '{"c":"b"}' ], - [ '{/*c*/"c":"b"}', '{"c":"b"}' ], - [ "/*\nc\r\n*/{\"c\":\"b\"}", '{"c":"b"}' ], - [ "//c\n{\"c\":\"b\"}", '{"c":"b"}' ], - [ "//c\r\n{\"c\":\"b\"}", '{"c":"b"}' ], - [ '{"a":"c"}//c', '{"a":"c"}' ], - [ "{\"a-c\"://c\n\"b\"}", '{"a-c":"b"}' ], - [ '{"/*a":"b"}', '{"/*a":"b"}' ], - [ '{"a":"//b"}', '{"a":"//b"}' ], - [ '{"a":"b/*c*/"}', '{"a":"b/*c*/"}' ], - [ "{\"\\\"/*a\":\"b\"}", "{\"\\\"/*a\":\"b\"}" ], - [ '', '' ], - [ '/*c', '' ], - [ '//c', '' ], - [ '"http://example.com"', '"http://example.com"' ], - [ "\0", "\0" ], - [ '"BlÃ¥bærsyltetøy"', '"BlÃ¥bærsyltetøy"' ], - ]; - } - - /** - * @covers FormatJson::stripComments - * @dataProvider provideStripComments - * @param string $json - * @param string $expect - */ - public function testStripComments( $json, $expect ) { - $this->assertSame( $expect, FormatJson::stripComments( $json ) ); - } - - public function provideParseStripComments() { - return [ - [ '/* blah */true', true ], - [ "// blah \ntrue", true ], - [ '[ "a" , /* blah */ "b" ]', [ 'a', 'b' ] ], - ]; - } - - /** - * @covers FormatJson::parse - * @covers FormatJson::stripComments - * @dataProvider provideParseStripComments - * @param string $json - * @param mixed $expect - */ - public function testParseStripComments( $json, $expect ) { - $st = FormatJson::parse( $json, FormatJson::STRIP_COMMENTS ); - $this->assertInstanceOf( Status::class, $st ); - $this->assertTrue( $st->isGood() ); - $this->assertEquals( $expect, $st->getValue() ); - } - - /** - * Generate a set of test cases for a particular combination of encoder options. - * - * @param array $unescapedGroups List of character groups to leave unescaped - * @return array Arrays of unencoded strings and corresponding encoded strings - */ - private static function getEncodeTestCases( array $unescapedGroups ) { - $groups = [ - 'always' => [ - // Forward slash (always unescaped) - '/' => '/', - - // Control characters - "\0" => '\u0000', - "\x08" => '\b', - "\t" => '\t', - "\n" => '\n', - "\r" => '\r', - "\f" => '\f', - "\x1f" => '\u001f', // representative example - - // Double quotes - '"' => '\"', - - // Backslashes - '\\' => '\\\\', - '\\\\' => '\\\\\\\\', - '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping - - // Line terminators - "\xe2\x80\xa8" => '\u2028', - "\xe2\x80\xa9" => '\u2029', - ], - 'unicode' => [ - "\xc3\xa9" => '\u00e9', - "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP - ], - 'xmlmeta' => [ - '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits - '>' => '\u003E', - '&' => '\u0026', - ], - ]; - - $cases = []; - foreach ( $groups as $name => $rules ) { - $leaveUnescaped = in_array( $name, $unescapedGroups ); - foreach ( $rules as $from => $to ) { - $cases[] = [ $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ]; - } - } - - return $cases; - } - - public function provideEmptyJsonKeyStrings() { - return [ - [ - '{"":"foo"}', - '{"":"foo"}', - '' - ], - [ - '{"_empty_":"foo"}', - '{"_empty_":"foo"}', - '_empty_' ], - [ - '{"\u005F\u0065\u006D\u0070\u0074\u0079\u005F":"foo"}', - '{"_empty_":"foo"}', - '_empty_' - ], - [ - '{"_empty_":"bar","":"foo"}', - '{"_empty_":"bar","":"foo"}', - '' - ], - [ - '{"":"bar","_empty_":"foo"}', - '{"":"bar","_empty_":"foo"}', - '_empty_' - ] - ]; - } - - /** - * @covers FormatJson::encode - * @covers FormatJson::decode - * @dataProvider provideEmptyJsonKeyStrings - * @param string $json - * - * Decoding behavior with empty keys can be surprising. - * See https://phabricator.wikimedia.org/T206411 - */ - public function testEmptyJsonKeyArray( $json, $expect, $php71Name ) { - // Decoding to array is consistent across supported PHP versions - $this->assertSame( $expect, FormatJson::encode( - FormatJson::decode( $json, true ) ) ); - - // Decoding to object differs between supported PHP versions - $obj = FormatJson::decode( $json ); - if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { - $this->assertEquals( 'foo', $obj->_empty_ ); - } else { - $this->assertEquals( 'foo', $obj->{$php71Name} ); - } - } } diff --git a/tests/phpunit/includes/libs/CookieTest.php b/tests/phpunit/includes/libs/CookieTest.php new file mode 100644 index 0000000000..e383be9650 --- /dev/null +++ b/tests/phpunit/includes/libs/CookieTest.php @@ -0,0 +1,52 @@ +assertEquals( $expected, $ok, $msg ); + } + + public static function cookieDomains() { + return [ + [ false, "org" ], + [ false, ".org" ], + [ true, "wikipedia.org" ], + [ true, ".wikipedia.org" ], + [ false, "co.uk" ], + [ false, ".co.uk" ], + [ false, "gov.uk" ], + [ false, ".gov.uk" ], + [ true, "supermarket.uk" ], + [ false, "uk" ], + [ false, ".uk" ], + [ false, "127.0.0." ], + [ false, "127." ], + [ false, "127.0.0.1." ], + [ true, "127.0.0.1" ], + [ false, "333.0.0.1" ], + [ true, "example.com" ], + [ false, "example.com." ], + [ true, ".example.com" ], + + [ true, ".example.com", "www.example.com" ], + [ false, "example.com", "www.example.com" ], + [ true, "127.0.0.1", "127.0.0.1" ], + [ false, "127.0.0.1", "localhost" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/HashRingTest.php b/tests/phpunit/includes/libs/HashRingTest.php index acaeb02558..4afe3b56d5 100644 --- a/tests/phpunit/includes/libs/HashRingTest.php +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -316,8 +316,8 @@ EOT; // Hash of known correct values from C code $this->assertEquals( - 'c69ac9eb7a8a630c0cded201cefeaace', - md5( $ketama_test( 1e5 ) ), + 'd1a4912a80e4654ec2e4e462c8b911c6', + md5( $ketama_test( 1e3 ) ), 'Ketama mode (large, MD5 check)' ); diff --git a/tests/phpunit/includes/libs/IPTest.php b/tests/phpunit/includes/libs/IPTest.php index 9ec53c00c7..9f2fb1c5d7 100644 --- a/tests/phpunit/includes/libs/IPTest.php +++ b/tests/phpunit/includes/libs/IPTest.php @@ -481,7 +481,7 @@ class IPTest extends PHPUnit\Framework\TestCase { $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); // Check internal logic - # 0 mask always result in array(0,0) + # 0 mask always result in [ 0, 0 ] $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) ); diff --git a/tests/phpunit/includes/libs/MWMessagePackTest.php b/tests/phpunit/includes/libs/MWMessagePackTest.php index 3890cf8e19..e9bffe1a33 100644 --- a/tests/phpunit/includes/libs/MWMessagePackTest.php +++ b/tests/phpunit/includes/libs/MWMessagePackTest.php @@ -5,8 +5,6 @@ */ class MWMessagePackTest extends MediaWikiTestCase { - use MediaWikiCoversValidator; - /** * Provides test cases for MWMessagePackTest::testMessagePack * diff --git a/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php new file mode 100644 index 0000000000..01b1c02362 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php @@ -0,0 +1,506 @@ +getMockForAbstractClass( ContainerInterface::class ) ) + ); + $this->assertSame( array_keys( ParamValidator::$STANDARD_TYPES ), $validator->knownTypes() ); + + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [], 'bar' => [] ] ] + ); + $validator->addTypeDef( 'baz', [] ); + try { + $validator->addTypeDef( 'baz', [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + } + $validator->overrideTypeDef( 'bar', null ); + $validator->overrideTypeDef( 'baz', [] ); + $this->assertSame( [ 'foo', 'baz' ], $validator->knownTypes() ); + + $this->assertTrue( $validator->hasTypeDef( 'foo' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bar' ) ); + $this->assertTrue( $validator->hasTypeDef( 'baz' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bazz' ) ); + } + + public function testGetTypeDef() { + $callbacks = new SimpleCallbacks( [] ); + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->method( 'createObject' ) + ->willReturnCallback( function ( $spec, $options ) use ( $callbacks ) { + $this->assertInternalType( 'array', $spec ); + $this->assertSame( + [ 'extraArgs' => [ $callbacks ], 'assertClass' => TypeDef::class ], $options + ); + $ret = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + $ret->spec = $spec; + return $ret; + } ); + $validator = new ParamValidator( $callbacks, $factory ); + + $def = $validator->getTypeDef( 'boolean' ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['boolean'], $def->spec ); + + $def = $validator->getTypeDef( [] ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['enum'], $def->spec ); + + $def = $validator->getTypeDef( 'missing' ); + $this->assertNull( $def ); + } + + public function testGetTypeDef_caching() { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ); + $def1 = $mb->getMockForAbstractClass(); + $def2 = $mb->getMockForAbstractClass(); + $this->assertNotSame( $def1, $def2, 'sanity check' ); + + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->expects( $this->once() )->method( 'createObject' )->willReturn( $def1 ); + + $validator = new ParamValidator( $callbacks, $factory, [ 'typeDefs' => [ + 'foo' => [], + 'bar' => $def2, + ] ] ); + + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // Second call doesn't re-call ObjectFactory + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // When registered a TypeDef directly, doesn't call ObjectFactory + $this->assertSame( $def2, $validator->getTypeDef( 'bar' ) ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionMessage Expected instance of Wikimedia\ParamValidator\TypeDef, got stdClass + */ + public function testGetTypeDef_error() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [ 'class' => \stdClass::class ] ] ] + ); + $validator->getTypeDef( 'foo' ); + } + + /** @dataProvider provideNormalizeSettings */ + public function testNormalizeSettings( $input, $expect ) { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'normalizeSettings' ] ); + $mock1 = $mb->getMockForAbstractClass(); + $mock1->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['foo'] = 'FooBar!'; + return $s; + } ); + $mock2 = $mb->getMockForAbstractClass(); + $mock2->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['bar'] = 'FooBar!'; + return $s; + } ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => $mock1, 'bar' => $mock2 ] ] + ); + + $this->assertSame( $expect, $validator->normalizeSettings( $input ) ); + } + + public static function provideNormalizeSettings() { + return [ + 'Plain value' => [ + 'ok?', + [ ParamValidator::PARAM_DEFAULT => 'ok?', ParamValidator::PARAM_TYPE => 'string' ], + ], + 'Simple array' => [ + [ 'test' => 'ok?' ], + [ 'test' => 'ok?', ParamValidator::PARAM_TYPE => 'NULL' ], + ], + 'A type with overrides' => [ + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?' ], + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?', 'foo' => 'FooBar!' ], + ], + ]; + } + + /** @dataProvider provideExplodeMultiValue */ + public function testExplodeMultiValue( $value, $limit, $expect ) { + $this->assertSame( $expect, ParamValidator::explodeMultiValue( $value, $limit ) ); + } + + public static function provideExplodeMultiValue() { + return [ + [ 'foobar', 100, [ 'foobar' ] ], + [ 'foo|bar|baz', 100, [ 'foo', 'bar', 'baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 100, [ 'foo', 'bar', 'baz' ] ], + [ 'foo|bar|baz', 2, [ 'foo', 'bar|baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 2, [ 'foo', "bar\x1Fbaz" ] ], + [ '|bar|baz', 100, [ '', 'bar', 'baz' ] ], + [ "\x1F\x1Fbar\x1Fbaz", 100, [ '', 'bar', 'baz' ] ], + [ '', 100, [] ], + [ "\x1F", 100, [] ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testGetValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->getValue( 'foo', 'default', [] ); + } + + /** @dataProvider provideGetValue */ + public function testGetValue( + $settings, $parseLimit, $get, $value, $isSensitive, $isDeprecated + ) { + $callbacks = new SimpleCallbacks( $get ); + $dummy = (object)[]; + $options = [ $dummy ]; + + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + // Mock the validateValue method so we can test only getValue + $validator = $this->getMockBuilder( ParamValidator::class ) + ->setConstructorArgs( [ + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ] ) + ->setMethods( [ 'validateValue' ] ) + ->getMock(); + $validator->expects( $this->once() )->method( 'validateValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( $value ), + $this->identicalTo( $settings ), + $this->identicalTo( $options ) + ) + ->willReturn( $dummy ); + + $this->assertSame( $dummy, $validator->getValue( 'foobar', $settings, $options ) ); + + $expectConditions = []; + if ( $isSensitive ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-sensitive', [] + ); + } + if ( $isDeprecated ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-deprecated', [] + ); + } + $this->assertEquals( $expectConditions, $callbacks->getRecordedConditions() ); + } + + public static function provideGetValue() { + $sen = [ ParamValidator::PARAM_SENSITIVE => true ]; + $dep = [ ParamValidator::PARAM_DEPRECATED => true ]; + $dflt = [ ParamValidator::PARAM_DEFAULT => 'DeFaUlT' ]; + return [ + 'Simple case' => [ [], false, [ 'foobar' => '!!!' ], '!!!', false, false ], + 'Not provided' => [ $sen + $dep, false, [], null, false, false ], + 'Not provided, default' => [ $sen + $dep + $dflt, true, [], 'DeFaUlT', false, false ], + 'Provided' => [ $dflt, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, false ], + 'Provided, sensitive' => [ $sen, false, [ 'foobar' => 'XYZ' ], 'XYZ', true, false ], + 'Provided, deprecated' => [ $dep, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, true ], + 'Provided array' => [ $dflt, false, [ 'foobar' => [ 'XYZ' ] ], [ 'XYZ' ], false, false ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testValidateValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->validateValue( 'foo', null, 'default', [] ); + } + + /** @dataProvider provideValidateValue */ + public function testValidateValue( + $value, $settings, $highLimits, $valuesList, $calls, $expect, $expectConditions = [], + $constructorOptions = [] + ) { + $callbacks = new SimpleCallbacks( [] ); + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + $dummy = (object)[]; + $options = [ $dummy, 'useHighLimits' => $highLimits ]; + $eOptions = $options; + $eOptions2 = $eOptions; + if ( $valuesList !== null ) { + $eOptions2['values-list'] = $valuesList; + } + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'validate', 'getEnumValues' ] ) + ->getMockForAbstractClass(); + $mockDef->method( 'getEnumValues' ) + ->with( + $this->identicalTo( 'foobar' ), $this->identicalTo( $settings ), $this->identicalTo( $eOptions ) + ) + ->willReturn( [ 'a', 'b', 'c', 'd', 'e', 'f' ] ); + $mockDef->expects( $this->exactly( count( $calls ) ) )->method( 'validate' )->willReturnCallback( + function ( $n, $v, $s, $o ) use ( $settings, $eOptions2, $calls ) { + $this->assertSame( 'foobar', $n ); + $this->assertSame( $settings, $s ); + $this->assertSame( $eOptions2, $o ); + + if ( !array_key_exists( $v, $calls ) ) { + $this->fail( "Called with unexpected value '$v'" ); + } + if ( $calls[$v] === null ) { + throw new ValidationException( $n, $v, $s, 'badvalue', [] ); + } + return $calls[$v]; + } + ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + $constructorOptions + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ); + + if ( $expect instanceof ValidationException ) { + try { + $validator->validateValue( 'foobar', $value, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertSame( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertSame( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $this->assertSame( + $expect, $validator->validateValue( 'foobar', $value, $settings, $options ) + ); + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $c ) { + $conditions[] = array_merge( [ $c->getFailureCode() ], $c->getFailureData() ); + } + $this->assertSame( $expectConditions, $conditions ); + } + } + + public static function provideValidateValue() { + return [ + 'No value' => [ null, [], false, null, [], null ], + 'No value, required' => [ + null, + [ ParamValidator::PARAM_REQUIRED => true ], + false, + null, + [], + new ValidationException( 'foobar', null, [], 'missingparam', [] ), + ], + 'Non-multi value' => [ 'abc', [], false, null, [ 'abc' => 'def' ], 'def' ], + 'Simple multi value' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Array multi value' => [ + [ 'a', 'b', 'c', 'd' ], + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Multi value with PARAM_ALL' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => true ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x"' => [ + 'x', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x", passing "*"' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + [ '*' ], + [ '*' => '?' ], + [ '?' ], + ], + + 'Too many values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + ], + 'Too many values as array' => [ + [ 'a', 'b', 'c', 'd' ], + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( + 'foobar', [ 'a', 'b', 'c', 'd' ], [], 'toomanyvalues', [ 'limit' => 2 ] + ), + ], + 'Not too many values for highlimits' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Too many values for highlimits' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + ], + + 'Too many values via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Not too many values for highlimits via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Too many values for highlimits via default' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + + 'Invalid values' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null ], + new ValidationException( 'foobar', 'b', [], 'badvalue', [] ), + ], + 'Ignored invalid values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_IGNORE_INVALID_VALUES => true, + ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null, 'c' => null, 'd' => 'D' ], + [ 'A', 'D' ], + [ + [ 'unrecognizedvalues', 'values' => [ 'b', 'c' ] ], + ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php new file mode 100644 index 0000000000..ebe1dcc1d8 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php @@ -0,0 +1,88 @@ + 'Foo!', 'bar' => null ], + [ + 'file1' => [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'tmp_name' => '...', + 'error' => UPLOAD_ERR_OK, + 'size' => 123, + ], + 'file2' => [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 0, + ], + ] + ); + + $this->assertTrue( $callbacks->hasParam( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'bar', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'baz', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'file1', [] ) ); + + $this->assertSame( 'Foo!', $callbacks->getValue( 'foo', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'bar', null, [] ) ); + $this->assertSame( 123, $callbacks->getValue( 'bar', 123, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'baz', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'file1', null, [] ) ); + + $this->assertFalse( $callbacks->hasUpload( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'bar', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file1', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file2', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'baz', [] ) ); + + $this->assertNull( $callbacks->getUploadedFile( 'foo', [] ) ); + $this->assertNull( $callbacks->getUploadedFile( 'bar', [] ) ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file1', [] ) + ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file2', [] ) + ); + $this->assertNull( $callbacks->getUploadedFile( 'baz', [] ) ); + + $file = $callbacks->getUploadedFile( 'file1', [] ); + $this->assertSame( 'example.txt', $file->getClientFilename() ); + $file = $callbacks->getUploadedFile( 'file2', [] ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + + $this->assertFalse( $callbacks->useHighLimits( [] ) ); + $this->assertFalse( $callbacks->useHighLimits( [ 'useHighLimits' => false ] ) ); + $this->assertTrue( $callbacks->useHighLimits( [ 'useHighLimits' => true ] ) ); + } + + public function testRecording() { + $callbacks = new SimpleCallbacks( [] ); + + $this->assertSame( [], $callbacks->getRecordedConditions() ); + + $ex1 = new ValidationException( 'foo', 'Foo!', [], 'foo', [] ); + $callbacks->recordCondition( $ex1, [] ); + $ex2 = new ValidationException( 'bar', null, [], 'barbar', [ 'bAr' => 'BaR' ] ); + $callbacks->recordCondition( $ex2, [] ); + $callbacks->recordCondition( $ex2, [] ); + $this->assertSame( [ $ex1, $ex2, $ex2 ], $callbacks->getRecordedConditions() ); + + $callbacks->clearRecordedConditions(); + $this->assertSame( [], $callbacks->getRecordedConditions() ); + $callbacks->recordCondition( $ex1, [] ); + $this->assertSame( [ $ex1 ], $callbacks->getRecordedConditions() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php new file mode 100644 index 0000000000..75afb33214 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php @@ -0,0 +1,47 @@ + BooleanDef::$TRUEVALS, + 'falsevals' => array_merge( BooleanDef::$FALSEVALS, [ 'the empty string' ] ), + ] ); + + foreach ( [ + [ BooleanDef::$TRUEVALS, true ], + [ BooleanDef::$FALSEVALS, false ], + [ [ '' ], false ], + [ [ '2', 'foobar' ], $ex ], + ] as list( $vals, $expect ) ) { + foreach ( $vals as $v ) { + yield "Value '$v'" => [ $v, $expect ]; + $v2 = ucfirst( $v ); + if ( $v2 !== $v ) { + yield "Value '$v2'" => [ $v2, $expect ]; + } + $v3 = strtoupper( $v ); + if ( $v3 !== $v2 ) { + yield "Value '$v3'" => [ $v3, $expect ]; + } + } + } + } + + public function provideStringifyValue() { + return [ + [ true, 'true' ], + [ false, 'false' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php new file mode 100644 index 0000000000..18d0aca29c --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php @@ -0,0 +1,64 @@ + [ 'a', 'b', 'c', 'd' ], + EnumDef::PARAM_DEPRECATED_VALUES => [ + 'b' => [ 'not-to-be' ], + 'c' => true, + ], + ]; + + return [ + 'Basic' => [ 'a', 'a', $settings ], + 'Deprecated' => [ 'c', 'c', $settings, [], [ [ 'deprecated-value', 'flag' => true ] ] ], + 'Deprecated with message' => [ + 'b', 'b', $settings, [], + [ [ 'deprecated-value', 'flag' => [ 'not-to-be' ] ] ], + ], + 'Bad value, non-multi' => [ + 'x', new ValidationException( 'test', 'x', $settings, 'badvalue', [] ), + $settings, + ], + 'Bad value, non-multi but looks like it' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'notmulti', [] ), + $settings, + ], + 'Bad value, multi' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'badvalue', [] ), + $settings + [ ParamValidator::PARAM_ISMULTI => true ], + [ 'values-list' => [ 'x|y' ] ], + ], + ]; + } + + public function provideGetEnumValues() { + return [ + 'Basic test' => [ + [ ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ] ], + [ 'a', 'b', 'c', 'd' ], + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + 'Array' => [ [ 1, 2, 3 ], '1|2|3' ], + 'Array with pipes' => [ [ 1, 2, '3|4', 5 ], "\x1f1\x1f2\x1f3|4\x1f5" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php new file mode 100644 index 0000000000..7bd053aa79 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php @@ -0,0 +1,122 @@ + [ '-0', 0 ], + 'Underflow is ok' => [ '1e-9999', 0 ], + + 'Empty decimal part' => [ '1.', new ValidationException( 'test', '1.', [], 'badfloat', [] ) ], + 'Bad sign' => [ ' 1', new ValidationException( 'test', ' 1', [], 'badfloat', [] ) ], + 'Comma as decimal separator or thousands grouping?' + => [ '1,234', new ValidationException( 'test', '1,234', [], 'badfloat', [] ) ], + 'U+2212 minus' => [ '−1', new ValidationException( 'test', '−1', [], 'badfloat', [] ) ], + 'Overflow' => [ '1e9999', new ValidationException( 'test', '1e9999', [], 'notfinite', [] ) ], + 'Overflow, -INF' + => [ '-1e9999', new ValidationException( 'test', '-1e9999', [], 'notfinite', [] ) ], + 'Bogus value' => [ 'foo', new ValidationException( 'test', 'foo', [], 'badfloat', [] ) ], + 'Bogus value (2)' => [ '123f4', new ValidationException( 'test', '123f4', [], 'badfloat', [] ) ], + 'Newline' => [ "123\n", new ValidationException( 'test', "123\n", [], 'badfloat', [] ) ], + ]; + } + + public function provideStringifyValue() { + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + + return [ + [ 1.2, '1.2' ], + [ 10 / 3, '3.' . str_repeat( '3', $digits - 1 ) ], + [ 1e100, '1.0e+100' ], + [ 6.022e-23, '6.022e-23' ], + ]; + } + + /** @dataProvider provideLocales */ + public function testStringifyValue_localeWeirdness( $locale ) { + static $cats = [ LC_ALL, LC_MONETARY, LC_NUMERIC ]; + + $curLocales = []; + foreach ( $cats as $c ) { + $curLocales[$c] = setlocale( $c, '0' ); + if ( $curLocales[$c] === false ) { + $this->markTestSkipped( 'Locale support is unavailable' ); + } + } + try { + foreach ( $cats as $c ) { + if ( setlocale( $c, $locale ) === false ) { + $this->markTestSkipped( "Locale \"$locale\" is unavailable" ); + } + } + + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), [] ); + $this->assertSame( '123456.789', $typeDef->stringifyValue( 'test', 123456.789, [], [] ) ); + $this->assertSame( '-123456.789', $typeDef->stringifyValue( 'test', -123456.789, [], [] ) ); + $this->assertSame( '1.0e+20', $typeDef->stringifyValue( 'test', 1e20, [], [] ) ); + $this->assertSame( '1.0e-20', $typeDef->stringifyValue( 'test', 1e-20, [], [] ) ); + } finally { + foreach ( $curLocales as $c => $v ) { + setlocale( $c, $v ); + } + } + } + + public function provideLocales() { + return [ + // May as well test these. + [ 'C' ], + [ 'C.UTF-8' ], + + // Some hopefullt-common locales with decimal_point = ',' and thousands_sep = '.' + [ 'de_DE' ], + [ 'de_DE.utf8' ], + [ 'es_ES' ], + [ 'es_ES.utf8' ], + + // This one, on my system at least, has decimal_point as U+066B. + [ 'ps_AF' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php new file mode 100644 index 0000000000..21fc9878c2 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php @@ -0,0 +1,159 @@ += 0; $i-- ) { + if ( $v[$i] === '9' ) { + $v[$i] = '0'; + } else { + $v[$i] = $v[$i] + 1; + return $v; + } + } + return '1' . $v; + } + + public function provideValidate() { + $badinteger = new ValidationException( 'test', '...', [], 'badinteger', [] ); + $belowminimum = new ValidationException( + 'test', '...', [], 'belowminimum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum2 = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $abovehighmaximum = new ValidationException( + 'test', '...', [], 'abovehighmaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $asWarn = function ( ValidationException $ex ) { + return [ $ex->getFailureCode() ] + $ex->getFailureData(); + }; + + $minmax = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + ]; + $minmax2 = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + IntegerDef::PARAM_MAX2 => 4, + ]; + $ignore = [ + IntegerDef::PARAM_IGNORE_RANGE => true, + ]; + $usehigh = [ 'useHighLimits' => true ]; + + return [ + [ '123', 123 ], + [ '-123', -123 ], + [ '000123', 123 ], + [ '000', 0 ], + [ '-0', 0 ], + [ (string)PHP_INT_MAX, PHP_INT_MAX ], + [ '0000' . PHP_INT_MAX, PHP_INT_MAX ], + [ (string)PHP_INT_MIN, PHP_INT_MIN ], + [ '-0000' . substr( PHP_INT_MIN, 1 ), PHP_INT_MIN ], + + 'Overflow' => [ self::plusOne( (string)PHP_INT_MAX ), $badinteger ], + 'Negative overflow' => [ '-' . self::plusOne( substr( PHP_INT_MIN, 1 ) ), $badinteger ], + + 'Float' => [ '1.5', $badinteger ], + 'Float (e notation)' => [ '1e1', $badinteger ], + 'Bad sign (space)' => [ ' 1', $badinteger ], + 'Bad sign (newline)' => [ "\n1", $badinteger ], + 'Bogus value' => [ 'foo', $badinteger ], + 'Bogus value (2)' => [ '1foo', $badinteger ], + 'Hex value' => [ '0x123', $badinteger ], + 'Newline' => [ "1\n", $badinteger ], + + 'Ok with range' => [ '1', 1, $minmax ], + 'Below minimum' => [ '-1', $belowminimum, $minmax ], + 'Below minimum, ignored' => [ '-1', 0, $minmax + $ignore, [], [ $asWarn( $belowminimum ) ] ], + 'Above maximum' => [ '3', $abovemaximum, $minmax ], + 'Above maximum, ignored' => [ '3', 2, $minmax + $ignore, [], [ $asWarn( $abovemaximum ) ] ], + 'Not above max2 but can\'t use it' => [ '3', $abovemaximum2, $minmax2, [] ], + 'Not above max2 but can\'t use it, ignored' + => [ '3', 2, $minmax2 + $ignore, [], [ $asWarn( $abovemaximum2 ) ] ], + 'Not above max2' => [ '3', 3, $minmax2, $usehigh ], + 'Above max2' => [ '5', $abovehighmaximum, $minmax2, $usehigh ], + 'Above max2, ignored' + => [ '5', 4, $minmax2 + $ignore, $usehigh, [ $asWarn( $abovehighmaximum ) ] ], + ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [], + ], + ]; + } + + public function provideDescribeSettings() { + return [ + 'Basic' => [ [], [], [] ], + 'Default' => [ + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'default' => '123' ], + [ 'default' => [ 'value' => '123' ] ], + ], + 'Min' => [ + [ ParamValidator::PARAM_DEFAULT => 123, IntegerDef::PARAM_MIN => 0 ], + [ 'default' => '123', 'min' => 0 ], + [ 'default' => [ 'value' => '123' ], 'min' => [ 'min' => 0, 'max' => '', 'max2' => '' ] ], + ], + 'Max' => [ + [ IntegerDef::PARAM_MAX => 2 ], + [ 'max' => 2 ], + [ 'max' => [ 'min' => '', 'max' => 2, 'max2' => '' ] ], + ], + 'Max2' => [ + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'max' => 2, 'max2' => 4 ], + [ 'max2' => [ 'min' => '', 'max' => 2, 'max2' => 4 ] ], + ], + 'Minmax' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2 ], + [ 'min' => 0, 'max' => 2 ], + [ 'minmax' => [ 'min' => 0, 'max' => 2, 'max2' => '' ] ], + ], + 'Minmax2' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'min' => 0, 'max' => 2, 'max2' => 4 ], + [ 'minmax2' => [ 'min' => 0, 'max' => 2, 'max2' => 4 ] ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php new file mode 100644 index 0000000000..2bf25e56e7 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php @@ -0,0 +1,49 @@ + true ]; + $max = [ IntegerDef::PARAM_MAX => 2 ]; + $max2 = [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ]; + + yield 'Max' => [ 'max', 2, $max ]; + yield 'Max, use high' => [ 'max', 2, $max, $useHigh ]; + yield 'Max2' => [ 'max', 2, $max2 ]; + yield 'Max2, use high' => [ 'max', 4, $max2, $useHigh ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [ IntegerDef::PARAM_MIN => 0 ] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MIN => 0 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 0 ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php new file mode 100644 index 0000000000..dd97903acd --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php @@ -0,0 +1,23 @@ + true ] ], + [ [ ParamValidator::PARAM_SENSITIVE => false ], [ ParamValidator::PARAM_SENSITIVE => true ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php new file mode 100644 index 0000000000..dd690dee97 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php @@ -0,0 +1,31 @@ + 'foo' ], [], [] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php new file mode 100644 index 0000000000..bae2f026ec --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php @@ -0,0 +1,71 @@ + true, + ]; + $maxBytes = [ + StringDef::PARAM_MAX_BYTES => 4, + ]; + $maxChars = [ + StringDef::PARAM_MAX_CHARS => 2, + ]; + + return [ + 'Basic' => [ '123', '123' ], + 'Empty' => [ '', '' ], + 'Empty, required' => [ + '', + new ValidationException( 'test', '', [], 'missingparam', [] ), + $req, + ], + 'Empty, required, allowed' => [ '', '', $req, [ 'allowEmptyWhenRequired' => true ] ], + 'Max bytes, ok' => [ 'abcd', 'abcd', $maxBytes ], + 'Max bytes, exceeded' => [ + 'abcde', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max bytes, ok (2)' => [ '😄', '😄', $maxBytes ], + 'Max bytes, exceeded (2)' => [ + '😭?', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max chars, ok' => [ 'ab', 'ab', $maxChars ], + 'Max chars, exceeded' => [ + 'abc', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + 'Max chars, ok (2)' => [ '😄😄', '😄😄', $maxChars ], + 'Max chars, exceeded (2)' => [ + '😭??', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php new file mode 100644 index 0000000000..8adf190d7a --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php @@ -0,0 +1,90 @@ + 'DateTime' ]; + $formatMW = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_MW ]; + + return [ + // We don't try to validate all formats supported by ConvertibleTimestamp, just + // some of the interesting ones. + 'ISO format' => [ '2018-02-03T04:05:06Z', $specific ], + 'ISO format with TZ' => [ '2018-02-03T00:05:06-04:00', $specific ], + 'ISO format without punctuation' => [ '20180203T040506', $specific ], + 'ISO format with ms' => [ '2018-02-03T04:05:06.999000Z', $specificMs ], + 'ISO format with ms without punctuation' => [ '20180203T040506.999', $specificMs ], + 'MW format' => [ '20180203040506', $specific ], + 'Generic format' => [ '2018-02-03 04:05:06', $specific ], + 'Generic format + GMT' => [ '2018-02-03 04:05:06 GMT', $specific ], + 'Generic format + TZ +0100' => [ '2018-02-03 05:05:06+0100', $specific ], + 'Generic format + TZ -01' => [ '2018-02-03 03:05:06-01', $specific ], + 'Seconds-since-epoch format' => [ '1517630706', $specific ], + 'Now' => [ 'now', $now ], + + // Warnings + 'Empty' => [ '', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + 'Zero' => [ '0', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + + // Error handling + 'Bad value' => [ + 'bogus', + new ValidationException( 'test', 'bogus', [], 'badtimestamp', [] ), + ], + + // Formatting + '=> DateTime' => [ 'now', $now->timestamp, $formatDT ], + '=> TS_MW' => [ 'now', '20190605195042', $formatMW ], + '=> TS_MW as default' => [ 'now', '20190605195042', [], [ 'defaultFormat' => TS_MW ] ], + '=> TS_MW overriding default' + => [ 'now', '20190605195042', $formatMW, [ 'defaultFormat' => TS_ISO_8601 ] ], + ]; + } + + public function provideStringifyValue() { + $specific = new ConvertibleTimestamp( '20180203040506' ); + + return [ + [ '20180203040506', '2018-02-03T04:05:06Z' ], + [ $specific, '2018-02-03T04:05:06Z' ], + [ $specific->timestamp, '2018-02-03T04:05:06Z' ], + [ $specific, '20180203040506', [], [ 'stringifyFormat' => TS_MW ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php new file mode 100644 index 0000000000..fa86c79a05 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php @@ -0,0 +1,193 @@ + $value ] ); + } + + /** + * Create an instance of the TypeDef subclass being tested + * + * @param SimpleCallbacks $callbacks From $this->getCallbacks() + * @param array $options Options array. + * @return TypeDef + */ + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + if ( static::$testClass === null ) { + throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ ); + } + + return new static::$testClass( $callbacks ); + } + + /** + * @dataProvider provideValidate + * @param mixed $value Value for getCallbacks() + * @param mixed|ValidationException $expect Expected result from TypeDef::validate(). + * If a ValidationException, it is expected that a ValidationException + * with matching failure code and data will be thrown. Otherwise, the return value must be equal. + * @param array $settings Settings array. + * @param array $options Options array + * @param array[] $expectConds Expected conditions reported. Each array is + * `[ $ex->getFailureCode() ] + $ex->getFailureData()`. + */ + public function testValidate( + $value, $expect, array $settings = [], array $options = [], array $expectConds = [] + ) { + $callbacks = $this->getCallbacks( $value, $options ); + $typeDef = $this->getInstance( $callbacks, $options ); + + if ( $expect instanceof ValidationException ) { + try { + $v = $typeDef->getValue( 'test', $settings, $options ); + $typeDef->validate( 'test', $v, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertEquals( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertEquals( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $v = $typeDef->getValue( 'test', $settings, $options ); + $this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) ); + } + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $ex ) { + $conditions[] = array_merge( [ $ex->getFailureCode() ], $ex->getFailureData() ); + } + $this->assertSame( $expectConds, $conditions ); + } + + /** + * @return array|Iterable + */ + abstract public function provideValidate(); + + /** + * @dataProvider provideNormalizeSettings + * @param array $settings + * @param array $expect + * @param array $options Options array + */ + public function testNormalizeSettings( array $settings, array $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->normalizeSettings( $settings ) ); + } + + /** + * @return array|Iterable + */ + public function provideNormalizeSettings() { + return [ + 'Basic test' => [ [ 'param-foo' => 'bar' ], [ 'param-foo' => 'bar' ] ], + ]; + } + + /** + * @dataProvider provideGetEnumValues + * @param array $settings + * @param array|null $expect + * @param array $options Options array + */ + public function testGetEnumValues( array $settings, $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->getEnumValues( 'test', $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideGetEnumValues() { + return [ + 'Basic test' => [ [], null ], + ]; + } + + /** + * @dataProvider provideStringifyValue + * @param mixed $value + * @param string|null $expect + * @param array $settings + * @param array $options Options array + */ + public function testStringifyValue( $value, $expect, array $settings = [], array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->stringifyValue( 'test', $value, $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + ]; + } + + /** + * @dataProvider provideDescribeSettings + * @param array $settings + * @param array $expectNormal + * @param array $expectCompact + * @param array $options Options array + */ + public function testDescribeSettings( + array $settings, array $expectNormal, array $expectCompact, array $options = [] + ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( + $expectNormal, + $typeDef->describeSettings( 'test', $settings, $options ), + 'Normal mode' + ); + $this->assertSame( + $expectCompact, + $typeDef->describeSettings( 'test', $settings, [ 'compact' => true ] + $options ), + 'Compact mode' + ); + } + + /** + * @return array|Iterable + */ + public function provideDescribeSettings() { + yield 'Basic test' => [ [], [], [] ]; + + foreach ( $this->provideStringifyValue() as $i => $v ) { + yield "Default value (from provideStringifyValue data set \"$i\")" => [ + [ ParamValidator::PARAM_DEFAULT => $v[0] ] + ( $v[2] ?? [] ), + [ 'default' => $v[1] ], + [ 'default' => [ 'value' => $v[1] ] ], + $v[3] ?? [], + ]; + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php new file mode 100644 index 0000000000..c81647ca09 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php @@ -0,0 +1,113 @@ + $value ] ); + } else { + return new SimpleCallbacks( [ 'test' => $value ] ); + } + } + + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + $ret = $this->getMockBuilder( UploadDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'getIniSize' ] ) + ->getMock(); + $ret->method( 'getIniSize' )->willReturn( $options['inisize'] ?? 2 * 1024 * 1024 ); + return $ret; + } + + private function makeUpload( $err = UPLOAD_ERR_OK ) { + return new UploadedFile( [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => '...', + 'error' => $err, + ] ); + } + + public function testGetNoFile() { + $typeDef = $this->getInstance( + $this->getCallbacks( $this->makeUpload( UPLOAD_ERR_NO_FILE ), [] ), + [] + ); + + $this->assertNull( $typeDef->getValue( 'test', [], [] ) ); + $this->assertNull( $typeDef->getValue( 'nothing', [], [] ) ); + } + + public function provideValidate() { + $okFile = $this->makeUpload(); + $iniFile = $this->makeUpload( UPLOAD_ERR_INI_SIZE ); + $exIni = new ValidationException( + 'test', '', [], 'badupload-inisize', [ 'size' => 2 * 1024 * 1024 * 1024 ] + ); + + return [ + 'Valid upload' => [ $okFile, $okFile ], + 'Not an upload' => [ + 'bar', + new ValidationException( 'test', 'bar', [], 'badupload-notupload', [] ), + ], + + 'Too big (bytes)' => [ $iniFile, $exIni, [], [ 'inisize' => 2 * 1024 * 1024 * 1024 ] ], + 'Too big (k)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'k' ] ], + 'Too big (K)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'K' ] ], + 'Too big (m)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'm' ] ], + 'Too big (M)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'M' ] ], + 'Too big (g)' => [ $iniFile, $exIni, [], [ 'inisize' => '2g' ] ], + 'Too big (G)' => [ $iniFile, $exIni, [], [ 'inisize' => '2G' ] ], + + 'Form size' => [ + $this->makeUpload( UPLOAD_ERR_FORM_SIZE ), + new ValidationException( 'test', '', [], 'badupload-formsize', [] ), + ], + 'Partial' => [ + $this->makeUpload( UPLOAD_ERR_PARTIAL ), + new ValidationException( 'test', '', [], 'badupload-partial', [] ), + ], + 'No tmp' => [ + $this->makeUpload( UPLOAD_ERR_NO_TMP_DIR ), + new ValidationException( 'test', '', [], 'badupload-notmpdir', [] ), + ], + 'Can\'t write' => [ + $this->makeUpload( UPLOAD_ERR_CANT_WRITE ), + new ValidationException( 'test', '', [], 'badupload-cantwrite', [] ), + ], + 'Ext abort' => [ + $this->makeUpload( UPLOAD_ERR_EXTENSION ), + new ValidationException( 'test', '', [], 'badupload-phpext', [] ), + ], + 'Unknown' => [ + $this->makeUpload( -43 ), // Should be safe from ever being an UPLOAD_ERR_* constant + new ValidationException( 'test', '', [], 'badupload-unknown', [ 'code' => -43 ] ), + ], + + 'Validating null' => [ + null, + new ValidationException( 'test', '', [], 'badupload', [] ), + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Yeah, right' => [ $this->makeUpload(), null ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php new file mode 100644 index 0000000000..7675a8cc3f --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php @@ -0,0 +1,79 @@ +getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( [ 'foobar' ], $typeDef->normalizeSettings( [ 'foobar' ] ) ); + $this->assertNull( $typeDef->getEnumValues( 'foobar', [], [] ) ); + $this->assertSame( '123', $typeDef->stringifyValue( 'foobar', 123, [], [] ) ); + } + + public function testGetValue() { + $options = [ (object)[] ]; + + $callbacks = $this->getMockBuilder( Callbacks::class )->getMockForAbstractClass(); + $callbacks->expects( $this->once() )->method( 'getValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( null ), + $this->identicalTo( $options ) + ) + ->willReturn( 'zyx' ); + + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + 'zyx', + $typeDef->getValue( 'foobar', [ ParamValidator::PARAM_DEFAULT => 'foo' ], $options ) + ); + } + + public function testDescribeSettings() { + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + [], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_TYPE => 'xxx' ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => '123', + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => [ 'value' => '123' ], + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'compact' => true ] + ) + ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php new file mode 100644 index 0000000000..9eaddf6882 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php @@ -0,0 +1,294 @@ +makeTemp( __FUNCTION__ ); + unlink( $filename ); + + $this->assertFileNotExists( $filename, 'sanity check' ); + $stream = new UploadedFileStream( $filename ); + } + + /** + * @expectedException RuntimeException + * @expectedExceptionMessage Failed to open file: + */ + public function testConstruct_notReadable() { + $filename = $this->makeTemp( __FUNCTION__ ); + + chmod( $filename, 0000 ); + $stream = new UploadedFileStream( $filename ); + } + + public function testCloseOnDestruct() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $fp = TestingAccessWrapper::newFromObject( $stream )->fp; + $this->assertSame( 'f', fread( $fp, 1 ), 'sanity check' ); + unset( $stream ); + $this->assertFalse( AtEase::quietCall( 'fread', $fp, 1 ) ); + } + + public function testToString() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // Always starts at the start of the stream + $stream->seek( 3 ); + $this->assertSame( 'foobar', (string)$stream ); + + // No exception when closed + $stream->close(); + $this->assertSame( '', (string)$stream ); + } + + public function testToString_Error() { + if ( !class_exists( \Error::class ) ) { + $this->markTestSkipped( 'No PHP Error class' ); + } + + // ... Yeah + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = $this->getMockBuilder( UploadedFileStream::class ) + ->setConstructorArgs( [ $filename ] ) + ->setMethods( [ 'getContents' ] ) + ->getMock(); + $stream->method( 'getContents' )->willReturnCallback( function () { + throw new \Error( 'Bogus' ); + } ); + $this->assertSame( '', (string)$stream ); + } + + public function testClose() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->close(); + + // Second call doesn't error + $stream->close(); + } + + public function testDetach() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // We got the file descriptor + $fp = $stream->detach(); + $this->assertNotNull( $fp ); + $this->assertSame( 'f', fread( $fp, 1 ) ); + + // Stream operations now fail. + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + + // Stream close doesn't affect the file descriptor + $stream->close(); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // Stream destruction doesn't affect the file descriptor + unset( $stream ); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // On a closed stream, we don't get a file descriptor + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertNull( $stream->detach() ); + } + + public function testGetSize() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + file_put_contents( $filename, 'foobarbaz' ); + $this->assertSame( 9, $stream->getSize() ); + + // Cached + file_put_contents( $filename, 'baz' ); + clearstatcache(); + $this->assertSame( 3, stat( $filename )['size'], 'sanity check' ); + $this->assertSame( 9, $stream->getSize() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertSame( null, $stream->getSize() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertSame( null, $stream->getSize() ); + } + + public function testSeekTell() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->seek( 2, SEEK_CUR ); + $this->assertSame( 4, $stream->tell() ); + $stream->seek( -5, SEEK_END ); + $this->assertSame( 1, $stream->tell() ); + $stream->read( 2 ); + $this->assertSame( 3, $stream->tell() ); + + $stream->close(); + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + try { + $stream->tell(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testEof() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertFalse( $stream->eof() ); + $stream->getContents(); + $this->assertTrue( $stream->eof() ); + $stream->seek( -1, SEEK_END ); + $this->assertFalse( $stream->eof() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertTrue( $stream->eof() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertInternalType( 'boolean', $stream->eof() ); + } + + public function testIsFuncs() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $this->assertTrue( $stream->isSeekable() ); + $this->assertTrue( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + + $stream->close(); + $this->assertFalse( $stream->isSeekable() ); + $this->assertFalse( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + } + + public function testRewind() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->rewind(); + $this->assertSame( 0, $stream->tell() ); + + $stream->close(); + try { + $stream->rewind(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testWrite() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + try { + $stream->write( 'foo' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testRead() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foo', $stream->read( 3 ) ); + $this->assertSame( 'bar', $stream->read( 10 ) ); + $this->assertSame( '', $stream->read( 10 ) ); + $stream->rewind(); + $this->assertSame( 'foobar', $stream->read( 10 ) ); + + $stream->close(); + try { + $stream->read( 1 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetContents() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foobar', $stream->getContents() ); + $this->assertSame( '', $stream->getContents() ); + $stream->seek( 3 ); + $this->assertSame( 'bar', $stream->getContents() ); + + $stream->close(); + try { + $stream->getContents(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetMetadata() { + // Whatever + $filename = $this->makeTemp( __FUNCTION__ ); + $fp = fopen( $filename, 'r' ); + $expect = stream_get_meta_data( $fp ); + fclose( $fp ); + + $stream = new UploadedFileStream( $filename ); + $this->assertSame( $expect, $stream->getMetadata() ); + foreach ( $expect as $k => $v ) { + $this->assertSame( $v, $stream->getMetadata( $k ) ); + } + $this->assertNull( $stream->getMetadata( 'bogus' ) ); + + $stream->close(); + try { + $stream->getMetadata(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php new file mode 100644 index 0000000000..80a74e7e74 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php @@ -0,0 +1,215 @@ +makeTemp( __FUNCTION__ ); + + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + + // getStream() fails for non-OK uploads + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file2 = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file2->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + // getStream() works + $stream = $file->getStream(); + $this->assertInstanceOf( StreamInterface::class, $stream ); + $stream->seek( 0 ); + $this->assertSame( 'foobar', $stream->getContents() ); + + // Second call also works + $this->assertInstanceOf( StreamInterface::class, $file->getStream() ); + + // getStream() throws after move, and the stream is invalidated too + $file->moveTo( $filename . '.xxx' ); + try { + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + try { + $stream->seek( 0 ); + $stream->getContents(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } finally { + unlink( $filename . '.xxx' ); // Clean up + } + + // getStream() fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], true ); + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + } + + public function testMoveTo() { + // Successful move + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + $file->moveTo( $filename . '.xxx' ); + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + + // Fails on a second move attempt + $this->assertFileNotExists( "$filename.yyy", 'sanity check' ); + try { + $file->moveTo( $filename . '.yyy' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + $this->assertFileNotExists( "$filename.yyy" ); + + // Fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => "$filename.aaa" ], false ); + $this->assertFileNotExists( "$filename.aaa", 'sanity check' ); + $this->assertFileNotExists( "$filename.bbb", 'sanity check' ); + try { + $file->moveTo( $filename . '.bbb' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + $this->assertFileNotExists( "$filename.aaa" ); + $this->assertFileNotExists( "$filename.bbb" ); + + // Fails for non-upload file (when not flagged to ignore that) + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ] ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Specified file is not an uploaded file', $ex->getMessage() ); + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + + // Fails for error uploads + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + } + + // Move failure triggers exception + $filename = $this->makeTemp( __FUNCTION__, 'file1' ); + $filename2 = $this->makeTemp( __FUNCTION__, 'file2' ); + $this->assertFileExists( $filename, 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename2 . DIRECTORY_SEPARATOR . 'foobar' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + } + + public function testInfoMethods() { + $filename = $this->makeTemp( __FUNCTION__ ); + $file = new UploadedFile( [ + 'name' => 'C:\\example.txt', + 'type' => 'text/plain', + 'size' => 1025, + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( 1025, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_OK, $file->getError() ); + $this->assertSame( 'C:\\example.txt', $file->getClientFilename() ); + $this->assertSame( 'text/plain', $file->getClientMediaType() ); + + // None of these are allowed to error + $file = new UploadedFile( [], false ); + $this->assertSame( null, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + + // "if none was provided" behavior, given that $_FILES often contains + // the empty string. + $file = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'size' => 100, + 'error' => UPLOAD_ERR_NO_FILE, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php new file mode 100644 index 0000000000..6e1bd6adbb --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php @@ -0,0 +1,82 @@ +isDir() ) { + rmdir( $file->getRealPath() ); + } else { + unlink( $file->getRealPath() ); + } + } + rmdir( self::$tmpdir ); + self::$tmpdir = null; + } + } + + protected static function assertTmpdir() { + if ( self::$tmpdir === null || !is_dir( self::$tmpdir ) ) { + self::fail( 'No temporary directory for ' . static::class ); + } + } + + /** + * @param string $prefix For tempnam() + * @param string $content Contents of the file + * @return string Filename + */ + protected function makeTemp( $prefix, $content = 'foobar' ) { + self::assertTmpdir(); + + $filename = tempnam( self::$tmpdir, $prefix ); + if ( $filename === false ) { + self::fail( 'Failed to create temporary file' ); + } + + self::assertSame( + strlen( $content ), + file_put_contents( $filename, $content ), + 'Writing test temporary file' + ); + + return $filename; + } + +} diff --git a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php index 4a09a2e00d..1d11fd8ef2 100644 --- a/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php +++ b/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php @@ -1,6 +1,7 @@ @@ -98,13 +99,13 @@ class BagOStuffTest extends MediaWikiTestCase { $this->cache->merge( $key, $callback, 5, 1 ), 'Non-blocking merge (CAS)' ); + if ( $this->cache instanceof MultiWriteBagOStuff ) { - $wrapper = \Wikimedia\TestingAccessWrapper::newFromObject( $this->cache ); - $n = count( $wrapper->caches ); + $wrapper = TestingAccessWrapper::newFromObject( $this->cache ); + $this->assertEquals( count( $wrapper->caches ), $calls ); } else { - $n = 1; + $this->assertEquals( 1, $calls ); } - $this->assertEquals( $n, $calls ); } /** @@ -115,10 +116,17 @@ class BagOStuffTest extends MediaWikiTestCase { $value = 'meow'; $this->cache->add( $key, $value, 5 ); - $this->assertTrue( $this->cache->changeTTL( $key, 5 ) ); + $this->assertEquals( $value, $this->cache->get( $key ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 10 ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 10 ) ); + $this->assertTrue( $this->cache->changeTTL( $key, 0 ) ); $this->assertEquals( $this->cache->get( $key ), $value ); $this->cache->delete( $key ); - $this->assertFalse( $this->cache->changeTTL( $key, 5 ) ); + $this->assertFalse( $this->cache->changeTTL( $key, 15 ) ); + + $this->cache->add( $key, $value, 5 ); + $this->assertTrue( $this->cache->changeTTL( $key, time() - 3600 ) ); + $this->assertFalse( $this->cache->get( $key ) ); } /** @@ -126,7 +134,9 @@ class BagOStuffTest extends MediaWikiTestCase { */ public function testAdd() { $key = $this->cache->makeKey( self::TEST_KEY ); + $this->assertFalse( $this->cache->get( $key ) ); $this->assertTrue( $this->cache->add( $key, 'test', 5 ) ); + $this->assertFalse( $this->cache->add( $key, 'test', 5 ) ); } /** @@ -237,20 +247,73 @@ class BagOStuffTest extends MediaWikiTestCase { $this->cache->makeKey( 'test-6' ) => 'ever' ]; - $this->cache->setMulti( $map, 5 ); + $this->assertTrue( $this->cache->setMulti( $map ) ); $this->assertEquals( $map, $this->cache->getMulti( array_keys( $map ) ) ); - $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ), 5 ) ); + $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ) ) ); + $this->assertEquals( + [], + $this->cache->getMulti( array_keys( $map ), BagOStuff::READ_LATEST ) + ); $this->assertEquals( [], $this->cache->getMulti( array_keys( $map ) ) ); } + /** + * @covers BagOStuff::get + * @covers BagOStuff::getMulti + * @covers BagOStuff::merge + * @covers BagOStuff::delete + */ + public function testSetSegmentable() { + $key = $this->cache->makeKey( self::TEST_KEY ); + $tiny = 418; + $small = wfRandomString( 32 ); + // 64 * 8 * 32768 = 16777216 bytes + $big = str_repeat( wfRandomString( 32 ) . '-' . wfRandomString( 32 ), 32768 ); + + $callback = function ( $cache, $key, $oldValue ) { + return $oldValue . '!'; + }; + + foreach ( [ $tiny, $small, $big ] as $value ) { + $this->cache->set( $key, $value, 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + $this->assertEquals( $value, $this->cache->get( $key ) ); + $this->assertEquals( $value, $this->cache->getMulti( [ $key ] )[$key] ); + + $this->assertTrue( $this->cache->merge( $key, $callback, 5 ) ); + $this->assertEquals( "$value!", $this->cache->get( $key ) ); + $this->assertEquals( "$value!", $this->cache->getMulti( [ $key ] )[$key] ); + + $this->assertTrue( $this->cache->deleteMulti( [ $key ] ) ); + $this->assertFalse( $this->cache->get( $key ) ); + $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) ); + + $this->cache->set( $key, "@$value", 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + $this->assertEquals( "@$value", $this->cache->get( $key ) ); + $this->assertTrue( $this->cache->delete( $key, BagOStuff::WRITE_PRUNE_SEGMENTS ) ); + $this->assertFalse( $this->cache->get( $key ) ); + $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) ); + } + + $this->cache->set( $key, 666, 10, BagOStuff::WRITE_ALLOW_SEGMENTS ); + + $this->assertEquals( 667, $this->cache->incr( $key ) ); + $this->assertEquals( 667, $this->cache->get( $key ) ); + + $this->assertEquals( 664, $this->cache->decr( $key, 3 ) ); + $this->assertEquals( 664, $this->cache->get( $key ) ); + + $this->assertTrue( $this->cache->delete( $key ) ); + $this->assertFalse( $this->cache->get( $key ) ); + } + /** * @covers BagOStuff::getScopedLock */ @@ -316,4 +379,11 @@ class BagOStuffTest extends MediaWikiTestCase { $this->assertTrue( $this->cache->unlock( $key2 ) ); $this->assertTrue( $this->cache->unlock( $key2 ) ); } + + public function tearDown() { + $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) ); + $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) . ':lock' ); + + parent::tearDown(); + } } diff --git a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php deleted file mode 100644 index 550ec0bd09..0000000000 --- a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php +++ /dev/null @@ -1,62 +0,0 @@ -writeCache = new HashBagOStuff(); - $this->readCache = new HashBagOStuff(); - $this->cache = new ReplicatedBagOStuff( [ - 'writeFactory' => $this->writeCache, - 'readFactory' => $this->readCache, - ] ); - } - - /** - * @covers ReplicatedBagOStuff::set - */ - public function testSet() { - $key = 'a key'; - $value = 'a value'; - $this->cache->set( $key, $value ); - - // Write to master. - $this->assertEquals( $value, $this->writeCache->get( $key ) ); - // Don't write to replica. Replication is deferred to backend. - $this->assertFalse( $this->readCache->get( $key ) ); - } - - /** - * @covers ReplicatedBagOStuff::get - */ - public function testGet() { - $key = 'a key'; - - $write = 'one value'; - $this->writeCache->set( $key, $write ); - $read = 'another value'; - $this->readCache->set( $key, $read ); - - // Read from replica. - $this->assertEquals( $read, $this->cache->get( $key ) ); - } - - /** - * @covers ReplicatedBagOStuff::get - */ - public function testGetAbsent() { - $key = 'a key'; - $value = 'a value'; - $this->writeCache->set( $key, $value ); - - // Don't read from master. No failover if value is absent. - $this->assertFalse( $this->cache->get( $key ) ); - } -} diff --git a/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php new file mode 100644 index 0000000000..5901bc108c --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php @@ -0,0 +1,81 @@ +assertEquals( $expectedId, $cp->getClientId() ); + } + + public function clientIdProvider() { + return [ + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + '', + '45e93a9c215c031d38b7c42d8e4700ca', + ], + [ + [ + 'ip' => '127.0.0.7', + 'agent' => "Totally-Not-FireFox" + ], + '', + 'b1d604117b51746c35c3df9f293c84dc' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-FireFox" + ], + '', + '731b4e06a65e2346b497fc811571c4d7' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + 'secret', + 'defff51ded73cd901253d874c9b2077d' + ] + ]; + } +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php index dd86a73eca..857f7090c9 100644 --- a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php @@ -13,6 +13,7 @@ use Wikimedia\Rdbms\ConnectionManager; * @author Daniel Kinzler */ class ConnectionManagerTest extends \PHPUnit\Framework\TestCase { + use \PHPUnit4And6Compat; /** * @return IDatabase|PHPUnit_Framework_MockObject_MockObject @@ -26,11 +27,7 @@ class ConnectionManagerTest extends \PHPUnit\Framework\TestCase { * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject */ private function getLoadBalancerMock() { - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $lb; + return $this->createMock( LoadBalancer::class ); } public function testGetReadConnection_nullGroups() { diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php index 8d7d104c1e..3492c3d9bb 100644 --- a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php @@ -13,6 +13,7 @@ use Wikimedia\Rdbms\SessionConsistentConnectionManager; * @author Daniel Kinzler */ class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase { + use \PHPUnit4And6Compat; /** * @return IDatabase|PHPUnit_Framework_MockObject_MockObject @@ -26,11 +27,7 @@ class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject */ private function getLoadBalancerMock() { - $lb = $this->getMockBuilder( LoadBalancer::class ) - ->disableOriginalConstructor() - ->getMock(); - - return $lb; + return $this->createMock( LoadBalancer::class ); } public function testGetReadConnection() { diff --git a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php index 33e5c3b3fb..fafeb4e20d 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php @@ -1,11 +1,10 @@ getMockBuilder( Database::class ) + $db = $this->getMockBuilder( IDatabase::class ) ->disableOriginalConstructor() ->getMock(); @@ -60,12 +59,6 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { $db->method( 'isOpen' )->willReturnCallback( function () use ( &$open ) { return $open; } ); - $db->method( 'open' )->willReturnCallback( function () use ( &$open ) { - $open = true; - - return $open; - } ); - $db->method( '__toString' )->willReturn( 'MOCK_DB' ); return $db; } @@ -82,7 +75,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { $lb = $this->getLoadBalancerMock(); $ref = new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testConstruct_params() { @@ -103,7 +96,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { DB_MASTER ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); $this->assertEquals( DB_MASTER, $ref->getReferenceRole() ); $ref2 = new DBConnRef( @@ -126,7 +119,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { private function innerMethodForTestDestruct( ILoadBalancer $lb ) { $ref = $lb->getConnectionRef( DB_REPLICA ); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testConstruct_failure() { @@ -157,7 +150,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase { public function testSelect() { // select should get passed through normally $ref = $this->getDBConnRef(); - $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertInstanceOf( IResultWrapper::class, $ref->select( 'whatever', '*' ) ); } public function testToString() { diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php index c0d25553dc..e5ca3df73e 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -1343,7 +1343,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } /** - * @covers Wikimedia\Rdbms\Database::registerTempTableWrite + * @covers Wikimedia\Rdbms\Database::getTempWrites */ public function testSessionTempTables() { $temp1 = $this->database->tableName( 'tmp_table_1' ); @@ -1488,7 +1488,8 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $triggerMap = [ '-' => '-', IDatabase::TRIGGER_COMMIT => 'tCommit', - IDatabase::TRIGGER_ROLLBACK => 'tRollback' + IDatabase::TRIGGER_ROLLBACK => 'tRollback', + IDatabase::TRIGGER_CANCEL => 'tCancel', ]; $pcCallback = function ( IDatabase $db ) use ( $fname ) { $this->database->query( "SELECT 0", $fname ); @@ -1518,6 +1519,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->cancelAtomic( __METHOD__ ); $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); @@ -1567,6 +1573,21 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'SELECT 3, tCommit AS t' ] ) ); + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onAtomicSectionCancel( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 2, tCancel AS t', + 'COMMIT', + ] ) ); + $makeCallback = function ( $id ) use ( $fname, $triggerMap ) { return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) { $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname ); @@ -1609,6 +1630,29 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { 'SELECT 3, tRollback AS t', 'SELECT 4, tCommit AS t' ] ) ); + + $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $makeCallback( 1 ), __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_level2' ); + $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $makeCallback( 2 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->onAtomicSectionCancel( $makeCallback( 3 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_level3' ); + $this->database->endAtomic( __METHOD__ . '_level2' ); + $this->database->onAtomicSectionCancel( $makeCallback( 4 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_level1' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'SAVEPOINT wikimedia_rdbms_atomic2', + 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 2, tCancel AS t', + 'SELECT 3, tCancel AS t', + 'COMMIT', + ] ) ); } /** @@ -1692,6 +1736,16 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $callback3Called = $trigger; $this->database->query( "SELECT 3", $fname ); }; + $callback4Called = 0; + $callback4 = function () use ( $fname, &$callback4Called ) { + $callback4Called++; + $this->database->query( "SELECT 4", $fname ); + }; + $callback5Called = 0; + $callback5 = function () use ( $fname, &$callback5Called ) { + $callback5Called++; + $this->database->query( "SELECT 5", $fname ); + }; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); @@ -1699,57 +1753,67 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); // phpcs:ignore Generic.Files.LineLength - $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); // phpcs:ignore Generic.Files.LineLength - $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->cancelAtomic( __METHOD__, $atomicId ); $this->database->endAtomic( __METHOD__ . '_outer' ); $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); try { $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId ); } catch ( DBUnexpectedError $e ) { @@ -1764,30 +1828,65 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); + $callback4Called = 0; + $callback5Called = 0; + $this->database->getLastSqls(); // flush $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); - $this->database->startAtomic( __METHOD__ . '_inner' ); - $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); - $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); - $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $this->database->cancelAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); $this->database->endAtomic( __METHOD__ . '_outer' ); - $this->assertNull( $callback1Called ); - $this->assertNull( $callback2Called ); - $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic2; SELECT 4; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); + + $callback4Called = 0; + $callback5Called = 0; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); + + $callback4Called = 0; + $callback5Called = 0; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $sectionId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback5, __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__, $sectionId ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' ); + $this->assertEquals( 1, $callback4Called ); + $this->assertEquals( 1, $callback5Called ); $wrapper = TestingAccessWrapper::newFromObject( $this->database ); $callback1Called = null; $callback2Called = null; $callback3Called = null; + $callback4Called = 0; $this->database->startAtomic( __METHOD__ . '_outer' ); $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); $this->database->startAtomic( __METHOD__ . '_inner' ); $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->onAtomicSectionCancel( $callback4, __METHOD__ ); $wrapper->trxStatus = Database::STATUS_TRX_ERROR; $this->database->cancelAtomic( __METHOD__ . '_inner' ); $this->database->cancelAtomic( __METHOD__ ); @@ -1795,6 +1894,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->assertNull( $callback1Called ); $this->assertNull( $callback2Called ); $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + $this->assertEquals( 1, $callback4Called ); } /** @@ -1876,9 +1976,25 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } } + /** + * @covers \Wikimedia\Rdbms\Database::onAtomicSectionCancel + */ + public function testNoAtomicSectionForCallback() { + try { + $this->database->onAtomicSectionCancel( function () { + }, __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'No atomic section is open (got ' . __METHOD__ . ').', + $ex->getMessage() + ); + } + } + /** * @expectedException \Wikimedia\Rdbms\DBTransactionStateError - * @covers \Wikimedia\Rdbms\Database::assertTransactionStatus + * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed */ public function testTransactionErrorState1() { $wrapper = TestingAccessWrapper::newFromObject( $this->database ); @@ -2091,6 +2207,9 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { $this->database->query( 'SELECT 1', $fname ); } ); + $this->database->onAtomicSectionCancel( function () use ( $fname ) { + $this->database->query( 'SELECT 2', $fname ); + } ); $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); $this->database->close(); $this->fail( 'Expected exception not thrown' ); @@ -2103,7 +2222,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { } $this->assertFalse( $this->database->isOpen() ); - $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK; SELECT 2' ); $this->assertEquals( 0, $this->database->trxLevel() ); } diff --git a/tests/phpunit/includes/libs/services/ServiceContainerTest.php b/tests/phpunit/includes/libs/services/ServiceContainerTest.php index 6674a15012..6e51883cfb 100644 --- a/tests/phpunit/includes/libs/services/ServiceContainerTest.php +++ b/tests/phpunit/includes/libs/services/ServiceContainerTest.php @@ -1,4 +1,5 @@ 'infinite', 'flags' => [ 'anononly' ], ], + 'preload' => [ new TitleValue( NS_USER_TALK, 'Logtestuser' ) ], + ], + ], + + // With blank page title (T224811) + [ + [ + 'type' => 'block', + 'action' => 'block', + 'comment' => 'Block comment', + 'user' => 0, + 'user_text' => 'Sysop', + 'namespace' => NS_USER, + 'title' => '', + 'params' => [], + ], + [ + 'text' => 'Sysop blocked (no username available) ' + . 'with an expiration time of indefinite', + 'api' => [ + 'duration' => 'infinite', + 'flags' => [], + ], + 'preload' => [], ], ], diff --git a/tests/phpunit/includes/logging/DeleteLogFormatterTest.php b/tests/phpunit/includes/logging/DeleteLogFormatterTest.php index 0e6855d9a9..6648c31c25 100644 --- a/tests/phpunit/includes/logging/DeleteLogFormatterTest.php +++ b/tests/phpunit/includes/logging/DeleteLogFormatterTest.php @@ -457,7 +457,7 @@ class DeleteLogFormatterTest extends LogFormatterTestCase { ], ], - // Legacy format + // Legacy formats [ [ 'type' => 'suppress', @@ -495,6 +495,27 @@ class DeleteLogFormatterTest extends LogFormatterTestCase { ], ], ], + [ + [ + 'type' => 'delete', + 'action' => 'revision', + 'comment' => 'Old rows might lack ofield/nfield (T224815)', + 'namespace' => NS_MAIN, + 'title' => 'Page', + 'params' => [ + 'oldid', + '1234', + ], + ], + [ + 'legacy' => true, + 'text' => 'User changed visibility of revisions on page Page', + 'api' => [ + 'type' => 'oldid', + 'ids' => [ '1234' ], + ], + ], + ] ]; } diff --git a/tests/phpunit/includes/logging/LogFormatterTest.php b/tests/phpunit/includes/logging/LogFormatterTest.php index f444d40c3e..4bb9d5ab1a 100644 --- a/tests/phpunit/includes/logging/LogFormatterTest.php +++ b/tests/phpunit/includes/logging/LogFormatterTest.php @@ -1,5 +1,7 @@ assertEquals( $expected, $logParam ); } + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeUserLink_empty() { + $params = [ '4:user-link:userLink' => ':' ]; + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + + $this->context->setLanguage( Language::factory( 'qqx' ) ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + $this->assertContains( '(empty-username)', $logParam ); + } + /** * @covers LogFormatter::newFromEntry * @covers LogFormatter::getActionText @@ -248,6 +267,20 @@ class LogFormatterTest extends MediaWikiLangTestCase { $this->assertEquals( $expected, $logParam ); } + /** + * @covers LogFormatter::getPerformerElement + */ + public function testGetPerformerElement() { + $entry = $this->newLogEntry( 'param', [] ); + $entry->setPerformer( new UserIdentityValue( 1328435, 'Test', 0 ) ); + + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $element = $formatter->getPerformerElement(); + $this->assertContains( 'User:Test', $element ); + } + /** * @covers LogFormatter::newFromEntry * @covers LogFormatter::getComment diff --git a/tests/phpunit/includes/logging/LogFormatterTestCase.php b/tests/phpunit/includes/logging/LogFormatterTestCase.php index 883af71240..fc2ab916cb 100644 --- a/tests/phpunit/includes/logging/LogFormatterTestCase.php +++ b/tests/phpunit/includes/logging/LogFormatterTestCase.php @@ -1,4 +1,5 @@ formatParametersForApi() ), 'Api log params is equal to expected array' ); + + if ( isset( $extra['preload'] ) ) { + $this->assertArrayEquals( + $this->getLinkTargetsAsStrings( $extra['preload'] ), + $this->getLinkTargetsAsStrings( + $formatter->getPreloadTitles() + ) + ); + } + } + + private function getLinkTargetsAsStrings( array $linkTargets ) { + return array_map( function ( LinkTarget $t ) { + return $t->getInterwiki() . ':' . $t->getNamespace() . ':' + . $t->getDBkey() . '#' . $t->getFragment(); + }, $linkTargets ); } protected function isLegacy( $extra ) { diff --git a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php index 83554d28e1..32a6b6ac0a 100644 --- a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php +++ b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php @@ -77,21 +77,18 @@ class BitmapMetadataHandlerTest extends MediaWikiTestCase { $meta = BitmapMetadataHandler::Jpeg( $this->filePath . 'iptc-timetest.jpg' ); + // raw date is 2020:07:13 14:04:05+11:32 $this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] ); + // raw date is 1997:03:02 03:01:02-03:00 $this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] ); - } - /** - * File has an invalid time (+ one valid but really weird time) - * that shouldn't be included - * @covers BitmapMetadataHandler::Jpeg - */ - public function testIPTCDatesInvalid() { $meta = BitmapMetadataHandler::Jpeg( $this->filePath . 'iptc-timetest-invalid.jpg' ); + // raw date is 1845:03:02 03:01:02-03:00 $this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] ); - $this->assertFalse( isset( $meta['DateTimeDigitized'] ) ); + // raw date is 1942:07:13 25:05:02+00:00 + $this->assertSame( '1942:07:14 01:05:02', $meta['DateTimeDigitized'] ); } /** diff --git a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php deleted file mode 100644 index 278b441bbd..0000000000 --- a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php +++ /dev/null @@ -1,110 +0,0 @@ -mediaPath = __DIR__ . '/../../data/media/'; - } - - /** - * Put in a file, and see if the metadata coming out is as expected. - * @param string $filename - * @param array $expected The extracted metadata. - * @dataProvider provideGetMetadata - * @covers GIFMetadataExtractor::getMetadata - */ - public function testGetMetadata( $filename, $expected ) { - $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); - $this->assertEquals( $expected, $actual ); - } - - public static function provideGetMetadata() { - $xmpNugget = << - - - - - The interwebs - - - - Bawolff - - - A file to test GIF - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -EOF; - $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat - - return [ - [ - 'nonanimated.gif', - [ - 'comment' => [ 'GIF test file ⁕ Created with GIMP' ], - 'duration' => 0.1, - 'frameCount' => 1, - 'looped' => false, - 'xmp' => '', - ] - ], - [ - 'animated.gif', - [ - 'comment' => [ 'GIF test file . Created with GIMP' ], - 'duration' => 2.4, - 'frameCount' => 4, - 'looped' => true, - 'xmp' => '', - ] - ], - - [ - 'animated-xmp.gif', - [ - 'xmp' => $xmpNugget, - 'duration' => 2.4, - 'frameCount' => 4, - 'looped' => true, - 'comment' => [ 'GIƒ·test·file' ], - ] - ], - ]; - } -} diff --git a/tests/phpunit/includes/media/IPTCTest.php b/tests/phpunit/includes/media/IPTCTest.php deleted file mode 100644 index 4b3ba0755c..0000000000 --- a/tests/phpunit/includes/media/IPTCTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertEquals( 'UTF-8', $res ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharset88591() { - // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 - // This data doesn't specify a charset. We're supposed to guess - // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharset88591b() { - /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ - /* \xC3 = Ã, \xB8 = ¸ */ - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ 'ÃÃø' ], $res['Keywords'] ); - } - - /** - * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. - * What should happen is the first "\xC3\xC3" should be dropped as invalid, - * leaving \xC3\xB8, which is ø - * @covers IPTC::parse - */ - public function testIPTCParseForcedUTFButInvalid() { - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" - . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ 'ø' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseNoCharsetUTF8() { - $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } - - /** - * Testing something that has 2 values for keyword - * @covers IPTC::parse - */ - public function testIPTCParseMulti() { - $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" - /* length */ . "\0\0\0\0\0\x0D" - . "\x1c\x02\x19" . "\x00\x01" . "\xBC" - . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼', '¼½' ], $res['Keywords'] ); - } - - /** - * @covers IPTC::parse - */ - public function testIPTCParseUTF8() { - // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. - $iptcData = - "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; - $res = IPTC::parse( $iptcData ); - $this->assertEquals( [ '¼' ], $res['Keywords'] ); - } -} diff --git a/tests/phpunit/includes/media/MediaHandlerTest.php b/tests/phpunit/includes/media/MediaHandlerTest.php deleted file mode 100644 index 7a052f6035..0000000000 --- a/tests/phpunit/includes/media/MediaHandlerTest.php +++ /dev/null @@ -1,68 +0,0 @@ -assertEquals( $expected, - $result, - "($width, $height, $max) wanted: {$expected}x$y, got: {z$result}x$y2" ); - } - - public static function provideTestFitBoxWidth() { - return array_merge( - static::generateTestFitBoxWidthData( 50, 50, [ - 50 => 50, - 17 => 17, - 18 => 18 ] - ), - static::generateTestFitBoxWidthData( 366, 300, [ - 50 => 61, - 17 => 21, - 18 => 22 ] - ), - static::generateTestFitBoxWidthData( 300, 366, [ - 50 => 41, - 17 => 14, - 18 => 15 ] - ), - static::generateTestFitBoxWidthData( 100, 400, [ - 50 => 12, - 17 => 4, - 18 => 4 ] - ) - ); - } - - /** - * Generate single test cases by combining the dimensions and tests contents - * - * It creates: - * [$width, $height, $max, $expected], - * [$width, $height, $max2, $expected2], ... - * out of parameters: - * $width, $height, { $max => $expected, $max2 => $expected2, ... } - * - * @param int $width - * @param int $height - * @param array $tests associative array of $max => $expected values - * @return array - */ - private static function generateTestFitBoxWidthData( $width, $height, $tests ) { - $result = []; - foreach ( $tests as $max => $expected ) { - $result[] = [ $width, $height, $max, $expected ]; - } - return $result; - } -} diff --git a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php deleted file mode 100644 index 6b94d0ae6c..0000000000 --- a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php +++ /dev/null @@ -1,201 +0,0 @@ -assertMetadata( $infile, $expected ); - } - - /** - * @dataProvider provideSvgFilesWithXMLMetadata - */ - public function testGetXMLMetadata( $infile, $expected ) { - $r = new XMLReader(); - $this->assertMetadata( $infile, $expected ); - } - - /** - * @dataProvider provideSvgUnits - */ - public function testScaleSVGUnit( $inUnit, $expected ) { - $this->assertEquals( - $expected, - SVGReader::scaleSVGUnit( $inUnit ), - 'SVG unit conversion and scaling failure' - ); - } - - function assertMetadata( $infile, $expected ) { - try { - $data = SVGMetadataExtractor::getMetadata( $infile ); - $this->assertEquals( $expected, $data, 'SVG metadata extraction test' ); - } catch ( MWException $e ) { - if ( $expected === false ) { - $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' ); - } else { - throw $e; - } - } - } - - public static function provideSvgFiles() { - $base = __DIR__ . '/../../data/media'; - - return [ - [ - "$base/Wikimedia-logo.svg", - [ - 'width' => 1024, - 'height' => 1024, - 'originalWidth' => '1024', - 'originalHeight' => '1024', - 'translations' => [], - ] - ], - [ - "$base/QA_icon.svg", - [ - 'width' => 60, - 'height' => 60, - 'originalWidth' => '60', - 'originalHeight' => '60', - 'translations' => [], - ] - ], - [ - "$base/Gtk-media-play-ltr.svg", - [ - 'width' => 60, - 'height' => 60, - 'originalWidth' => '60.0000000', - 'originalHeight' => '60.0000000', - 'translations' => [], - ] - ], - [ - "$base/Toll_Texas_1.svg", - // This file triggered T33719, needs entity expansion in the xmlns checks - [ - 'width' => 385, - 'height' => 385, - 'originalWidth' => '385', - 'originalHeight' => '385.0004883', - 'translations' => [], - ] - ], - [ - "$base/Tux.svg", - [ - 'width' => 512, - 'height' => 594, - 'originalWidth' => '100%', - 'originalHeight' => '100%', - 'title' => 'Tux', - 'translations' => [], - 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', - ] - ], - [ - "$base/Speech_bubbles.svg", - [ - 'width' => 627, - 'height' => 461, - 'originalWidth' => '17.7cm', - 'originalHeight' => '13cm', - 'translations' => [ - 'de' => SVGReader::LANG_FULL_MATCH, - 'fr' => SVGReader::LANG_FULL_MATCH, - 'nl' => SVGReader::LANG_FULL_MATCH, - 'tlh-ca' => SVGReader::LANG_FULL_MATCH, - 'tlh' => SVGReader::LANG_PREFIX_MATCH - ], - ] - ], - [ - "$base/Soccer_ball_animated.svg", - [ - 'width' => 150, - 'height' => 150, - 'originalWidth' => '150', - 'originalHeight' => '150', - 'animated' => true, - 'translations' => [] - ], - ], - [ - "$base/comma_separated_viewbox.svg", - [ - 'width' => 512, - 'height' => 594, - 'originalWidth' => '100%', - 'originalHeight' => '100%', - 'translations' => [] - ], - ], - ]; - } - - public static function provideSvgFilesWithXMLMetadata() { - $base = __DIR__ . '/../../data/media'; - // phpcs:disable Generic.Files.LineLength - $metadata = ' - - image/svg+xml - - - '; - // phpcs:enable - - $metadata = str_replace( "\r", '', $metadata ); // Windows compat - return [ - [ - "$base/US_states_by_total_state_tax_revenue.svg", - [ - 'height' => 593, - 'metadata' => $metadata, - 'width' => 959, - 'originalWidth' => '958.69', - 'originalHeight' => '592.78998', - 'translations' => [], - ] - ], - ]; - } - - public static function provideSvgUnits() { - return [ - [ '1' , 1 ], - [ '1.1' , 1.1 ], - [ '0.1' , 0.1 ], - [ '.1' , 0.1 ], - [ '1e2' , 100 ], - [ '1E2' , 100 ], - [ '+1' , 1 ], - [ '-1' , -1 ], - [ '-1.1' , -1.1 ], - [ '1e+2' , 100 ], - [ '1e-2' , 0.01 ], - [ '10px' , 10 ], - [ '10pt' , 10 * 1.25 ], - [ '10pc' , 10 * 15 ], - [ '10mm' , 10 * 3.543307 ], - [ '10cm' , 10 * 35.43307 ], - [ '10in' , 10 * 90 ], - [ '10em' , 10 * 16 ], - [ '10ex' , 10 * 12 ], - [ '10%' , 51.2 ], - [ '10 px' , 10 ], - // Invalid values - [ '1e1.1', 10 ], - [ '10bp', 10 ], - [ 'p10', null ], - ]; - } -} diff --git a/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php b/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php deleted file mode 100644 index 432754b602..0000000000 --- a/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php +++ /dev/null @@ -1,107 +0,0 @@ -cache = new MemcachedBagOStuff( [ 'keyspace' => 'test' ] ); - } - - /** - * @covers MemcachedBagOStuff::makeKey - */ - public function testKeyNormalization() { - $this->assertEquals( - 'test:vanilla', - $this->cache->makeKey( 'vanilla' ) - ); - - $this->assertEquals( - 'test:punctuation_marks_are_ok:!@$^&*()', - $this->cache->makeKey( 'punctuation_marks_are_ok', '!@$^&*()' ) - ); - - $this->assertEquals( - 'test:but_spaces:hashes%23:and%0Anewlines:are_not', - $this->cache->makeKey( 'but spaces', 'hashes#', "and\nnewlines", 'are_not' ) - ); - - $this->assertEquals( - 'test:this:key:contains:%F0%9D%95%9E%F0%9D%95%A6%F0%9D%95%9D%F0%9D%95%A5%F0%9' . - 'D%95%9A%F0%9D%95%93%F0%9D%95%AA%F0%9D%95%A5%F0%9D%95%96:characters', - $this->cache->makeKey( 'this', 'key', 'contains', '𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖', 'characters' ) - ); - - $this->assertEquals( - 'test:this:key:contains:#c118f92685a635cb843039de50014c9c', - $this->cache->makeKey( 'this', 'key', 'contains', '𝕥𝕠𝕠 𝕞𝕒𝕟𝕪 𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖 𝕔𝕙𝕒𝕣𝕒𝕔𝕥𝕖𝕣𝕤' ) - ); - - $this->assertEquals( - 'test:BagOStuff-long-key:##dc89dcb43b28614da27660240af478b5', - $this->cache->makeKey( '𝕖𝕧𝕖𝕟', '𝕚𝕗', '𝕨𝕖', '𝕄𝔻𝟝', '𝕖𝕒𝕔𝕙', - '𝕒𝕣𝕘𝕦𝕞𝕖𝕟𝕥', '𝕥𝕙𝕚𝕤', '𝕜𝕖𝕪', '𝕨𝕠𝕦𝕝𝕕', '𝕤𝕥𝕚𝕝𝕝', '𝕓𝕖', '𝕥𝕠𝕠', '𝕝𝕠𝕟𝕘' ) - ); - - $this->assertEquals( - 'test:%23%235820ad1d105aa4dc698585c39df73e19', - $this->cache->makeKey( '##5820ad1d105aa4dc698585c39df73e19' ) - ); - - $this->assertEquals( - 'test:percent_is_escaped:!@$%25^&*()', - $this->cache->makeKey( 'percent_is_escaped', '!@$%^&*()' ) - ); - - $this->assertEquals( - 'test:colon_is_escaped:!@$%3A^&*()', - $this->cache->makeKey( 'colon_is_escaped', '!@$:^&*()' ) - ); - - $this->assertEquals( - 'test:long_key_part_hashed:#0244f7b1811d982dd932dd7de01465ac', - $this->cache->makeKey( 'long_key_part_hashed', str_repeat( 'y', 500 ) ) - ); - } - - /** - * @dataProvider validKeyProvider - * @covers MemcachedBagOStuff::validateKeyEncoding - */ - public function testValidateKeyEncoding( $key ) { - $this->assertSame( $key, $this->cache->validateKeyEncoding( $key ) ); - } - - public function validKeyProvider() { - return [ - 'empty' => [ '' ], - 'digits' => [ '09' ], - 'letters' => [ 'AZaz' ], - 'ASCII special characters' => [ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' ], - ]; - } - - /** - * @dataProvider invalidKeyProvider - * @covers MemcachedBagOStuff::validateKeyEncoding - */ - public function testValidateKeyEncodingThrowsException( $key ) { - $this->setExpectedException( Exception::class ); - $this->cache->validateKeyEncoding( $key ); - } - - public function invalidKeyProvider() { - return [ - [ "\x00" ], - [ ' ' ], - [ "\x1F" ], - [ "\x7F" ], - [ "\x80" ], - [ "\xFF" ], - ]; - } -} diff --git a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php deleted file mode 100644 index 66754fc93f..0000000000 --- a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php +++ /dev/null @@ -1,90 +0,0 @@ -client = - $this->getMockBuilder( MultiHttpClient::class ) - ->setConstructorArgs( [ [] ] ) - ->setMethods( [ 'run' ] ) - ->getMock(); - $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] ); - } - - public function testGet() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42' - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], 's:8:"somedata";', 0 ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertEquals( 'somedata', $result ); - } - - public function testGetNotExist() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42' - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - } - - public function testGetBadClient() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42' - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() ); - } - - public function testGetBadServer() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'GET', - 'url' => 'http://test/rest/42xyz42' - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] ); - $result = $this->bag->get( '42xyz42' ); - $this->assertFalse( $result ); - $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() ); - } - - public function testPut() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'PUT', - 'url' => 'http://test/rest/42xyz42', - 'body' => 's:8:"postdata";' - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); - $result = $this->bag->set( '42xyz42', 'postdata' ); - $this->assertTrue( $result ); - } - - public function testDelete() { - $this->client->expects( $this->once() )->method( 'run' )->with( [ - 'method' => 'DELETE', - 'url' => 'http://test/rest/42xyz42', - // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) - ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); - $result = $this->bag->delete( '42xyz42' ); - $this->assertTrue( $result ); - } -} diff --git a/tests/phpunit/includes/page/ArticleTablesTest.php b/tests/phpunit/includes/page/ArticleTablesTest.php index 34b25251c1..5c5ce5c292 100644 --- a/tests/phpunit/includes/page/ArticleTablesTest.php +++ b/tests/phpunit/includes/page/ArticleTablesTest.php @@ -4,6 +4,7 @@ * @group Database */ class ArticleTablesTest extends MediaWikiLangTestCase { + /** * Make sure that T16404 doesn't strike again. We don't want * templatelinks based on the user language when {{int:}} is used, only the @@ -16,7 +17,7 @@ class ArticleTablesTest extends MediaWikiLangTestCase { $title = Title::newFromText( 'T16404' ); $page = WikiPage::factory( $title ); $user = new User(); - $user->mRights = [ 'createpage', 'edit', 'purge' ]; + $this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge' ] ); $this->setContentLang( 'es' ); $this->setUserLang( 'fr' ); diff --git a/tests/phpunit/includes/page/ArticleViewTest.php b/tests/phpunit/includes/page/ArticleViewTest.php index 524fbdccef..b0178fd6e0 100644 --- a/tests/phpunit/includes/page/ArticleViewTest.php +++ b/tests/phpunit/includes/page/ArticleViewTest.php @@ -1,4 +1,5 @@ addGroup( 'sysop' ); // Make the test user a sysop + MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache(); $token = $admin->getEditToken( 'rollback' ); $errors = $page->doRollback( $secondUser->getName(), diff --git a/tests/phpunit/includes/page/WikiPageMcrWriteBothDbTest.php b/tests/phpunit/includes/page/WikiPageMcrWriteBothDbTest.php index ef1cb63067..580e9258d7 100644 --- a/tests/phpunit/includes/page/WikiPageMcrWriteBothDbTest.php +++ b/tests/phpunit/includes/page/WikiPageMcrWriteBothDbTest.php @@ -1,4 +1,5 @@ assertEquals( $msg, 'f', 'Works escaped' ); } + public function provideTalkpagename() { + yield [ 'Talk:Foo bar', 'foo_bar' ]; + yield [ 'Talk:Foo', ' foo ' ]; + yield [ 'Talk:Foo', 'Talk:Foo' ]; + yield [ 'User talk:Foo', 'User:foo' ]; + yield [ '', 'Special:Foo' ]; + yield [ '', '' ]; + yield [ '', ' ' ]; + yield [ '', '__' ]; + yield [ '', '#xyzzy' ]; + yield [ '', '#' ]; + yield [ '', ':' ]; + yield [ '', ':#' ]; + yield [ '', 'User:' ]; + yield [ '', 'User:#' ]; + } + + /** + * @dataProvider provideTalkpagename + */ + public function testTalkpagename( $expected, $title ) { + $parser = MediaWikiServices::getInstance()->getParser(); + + $this->assertSame( $expected, CoreParserFunctions::talkpagename( $parser, $title ) ); + } + + public function provideSubjectpagename() { + yield [ 'Foo bar', 'Talk:foo_bar' ]; + yield [ 'Foo', ' Talk:foo ' ]; + yield [ 'User:Foo', 'User talk:foo' ]; + yield [ 'Special:Foo', 'Special:Foo' ]; + yield [ '', '' ]; + yield [ '', ' ' ]; + yield [ '', '__' ]; + yield [ '', '#xyzzy' ]; + yield [ '', '#' ]; + yield [ '', ':' ]; + yield [ '', ':#' ]; + yield [ '', 'Talk:' ]; + yield [ '', 'User talk:#' ]; + yield [ '', 'User:#' ]; + } + + /** + * @dataProvider provideTalkpagename + */ + public function testSubjectpagename( $expected, $title ) { + $parser = MediaWikiServices::getInstance()->getParser(); + + $this->assertSame( $expected, CoreParserFunctions::talkpagename( $parser, $title ) ); + } + } diff --git a/tests/phpunit/includes/parser/ParserFactoryTest.php b/tests/phpunit/includes/parser/ParserFactoryTest.php index f37bdfcbe3..048256d255 100644 --- a/tests/phpunit/includes/parser/ParserFactoryTest.php +++ b/tests/phpunit/includes/parser/ParserFactoryTest.php @@ -22,9 +22,11 @@ class ParserFactoryTest extends MediaWikiTestCase { public function testAllArgumentsWerePassed() { $factoryConstructor = new ReflectionMethod( 'ParserFactory', '__construct' ); $mocks = []; - foreach ( $factoryConstructor->getParameters() as $param ) { + foreach ( $factoryConstructor->getParameters() as $index => $param ) { $type = (string)$param->getType(); - if ( $type === 'array' ) { + if ( $index === 0 ) { + $val = $this->createMock( 'MediaWiki\Config\ServiceOptions' ); + } elseif ( $type === 'array' ) { $val = [ 'porcupines will tell me your secrets' . count( $mocks ) ]; } elseif ( class_exists( $type ) || interface_exists( $type ) ) { $val = $this->createMock( $type ); @@ -52,4 +54,54 @@ class ParserFactoryTest extends MediaWikiTestCase { $this->assertCount( 0, $mocks, 'Not all arguments to the ParserFactory constructor were ' . 'found in Parser member variables' ); } + + public function provideConstructorArguments() { + // Create a mock Config object that will satisfy ServiceOptions::__construct + $mockConfig = $this->createMock( 'Config' ); + $mockConfig->method( 'has' )->willReturn( true ); + $mockConfig->method( 'get' )->willReturn( 'I like otters.' ); + + $mocks = [ + [ 'the plural of platypus...' ], + $this->createMock( 'MagicWordFactory' ), + $this->createMock( 'Language' ), + '...is platypodes', + $this->createMock( 'MediaWiki\Special\SpecialPageFactory' ), + $mockConfig, + $this->createMock( 'MediaWiki\Linker\LinkRendererFactory' ), + ]; + + yield 'args_without_namespace_info' => [ + $mocks, + ]; + yield 'args_with_namespace_info' => [ + array_merge( $mocks, [ $this->createMock( 'NamespaceInfo' ) ] ), + ]; + } + + /** + * @dataProvider provideConstructorArguments + * @covers ParserFactory::__construct + */ + public function testBackwardsCompatibleConstructorArguments( $args ) { + $this->hideDeprecated( 'ParserFactory::__construct with Config parameter' ); + $factory = new ParserFactory( ...$args ); + $parser = $factory->create(); + + // It is expected that these are not present on the parser. + unset( $args[5] ); + unset( $args[0] ); + + foreach ( ( new ReflectionObject( $parser ) )->getProperties() as $prop ) { + $prop->setAccessible( true ); + foreach ( $args as $idx => $mockTest ) { + if ( $prop->getValue( $parser ) === $mockTest ) { + unset( $args[$idx] ); + } + } + } + + $this->assertCount( 0, $args, 'Not all arguments to the ParserFactory constructor were ' . + 'found in Parser member variables' ); + } } diff --git a/tests/phpunit/includes/parser/ParserMethodsTest.php b/tests/phpunit/includes/parser/ParserMethodsTest.php index cf0b650829..651c87161c 100644 --- a/tests/phpunit/includes/parser/ParserMethodsTest.php +++ b/tests/phpunit/includes/parser/ParserMethodsTest.php @@ -1,4 +1,5 @@ mParseStartTime; - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { @@ -889,7 +890,7 @@ EOF $a->resetParseStartTime(); $aClocks = $a->mParseStartTime; - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { @@ -901,7 +902,7 @@ EOF $a = new ParserOutput(); $a = TestingAccessWrapper::newFromObject( $a ); - $a->mergeInternalMetaDataFrom( $b->object, 'b' ); + $a->mergeInternalMetaDataFrom( $b->object ); $mergedClocks = $a->mParseStartTime; foreach ( $mergedClocks as $clock => $timestamp ) { diff --git a/tests/phpunit/includes/parser/ParserTest.php b/tests/phpunit/includes/parser/ParserTest.php new file mode 100644 index 0000000000..19341f55a4 --- /dev/null +++ b/tests/phpunit/includes/parser/ParserTest.php @@ -0,0 +1,98 @@ +createMock( 'Config' ); + $mockConfig->method( 'has' )->willReturn( true ); + $mockConfig->method( 'get' )->willReturn( 'I like otters.' ); + + $newArgs = [ + $this->createMock( 'MediaWiki\Config\ServiceOptions' ), + $this->createMock( 'MagicWordFactory' ), + $this->createMock( 'Language' ), + $this->createMock( 'ParserFactory' ), + 'a snail can sleep for three years', + $this->createMock( 'MediaWiki\Special\SpecialPageFactory' ), + $this->createMock( 'MediaWiki\Linker\LinkRendererFactory' ), + $this->createMock( 'NamespaceInfo' ) + ]; + + $oldArgs = [ + [], + $this->createMock( 'MagicWordFactory' ), + $this->createMock( 'Language' ), + $this->createMock( 'ParserFactory' ), + 'a snail can sleep for three years', + $this->createMock( 'MediaWiki\Special\SpecialPageFactory' ) + ]; + + yield 'current_args_without_namespace_info' => [ + $newArgs, + ]; + + yield 'backward_compatible_args_minimal' => [ + array_merge( $oldArgs ), + ]; + + yield 'backward_compatible_args_with_config' => [ + array_merge( $oldArgs, [ $mockConfig ] ), + ]; + + yield 'backward_compatible_args_with_link_renderer' => [ + array_merge( $oldArgs, [ + $mockConfig, + $this->createMock( 'MediaWiki\Linker\LinkRendererFactory' ) + ] ), + ]; + + yield 'backward_compatible_args_with_ns_info' => [ + array_merge( $oldArgs, [ + $mockConfig, + $this->createMock( 'MediaWiki\Linker\LinkRendererFactory' ), + $this->createMock( 'NamespaceInfo' ) + ] ), + ]; + } + + /** + * @dataProvider provideConstructorArguments + * @covers Parser::__construct + */ + public function testBackwardsCompatibleConstructorArguments( $args ) { + $parser = new Parser( ...$args ); + + $refObject = new ReflectionObject( $parser ); + + // If testing backwards compatibility, test service options separately + if ( is_array( $args[0] ) ) { + $svcOptionsProp = $refObject->getProperty( 'svcOptions' ); + $svcOptionsProp->setAccessible( true ); + $this->assertType( 'MediaWiki\Config\ServiceOptions', + $svcOptionsProp->getValue( $parser ) + ); + unset( $args[0] ); + + // If a Config is passed, the fact that we were able to create a ServiceOptions + // instance without error from it proves that this argument works. + if ( isset( $args[6] ) ) { + unset( $args[6] ); + } + } + + foreach ( $refObject->getProperties() as $prop ) { + $prop->setAccessible( true ); + foreach ( $args as $idx => $mockTest ) { + if ( $prop->getValue( $parser ) === $mockTest ) { + unset( $args[$idx] ); + } + } + } + + $this->assertCount( 0, $args, 'Not all arguments to the Parser constructor were ' . + 'found on the Parser object' ); + } +} diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php index 6b3e05da51..3b2b1050bd 100644 --- a/tests/phpunit/includes/parser/PreprocessorTest.php +++ b/tests/phpunit/includes/parser/PreprocessorTest.php @@ -48,6 +48,9 @@ class PreprocessorTest extends MediaWikiTestCase { $this->mOptions = ParserOptions::newFromUserAndLang( new User, MediaWikiServices::getInstance()->getContentLanguage() ); + # Suppress deprecation warning for Preprocessor_DOM while testing + $this->hideDeprecated( 'Preprocessor_DOM::__construct' ); + $this->mPreprocessors = []; foreach ( self::$classNames as $className ) { $this->mPreprocessors[$className] = new $className( $this ); diff --git a/tests/phpunit/includes/parser/SanitizerTest.php b/tests/phpunit/includes/parser/SanitizerTest.php index 1f6f4e873b..e665d672f8 100644 --- a/tests/phpunit/includes/parser/SanitizerTest.php +++ b/tests/phpunit/includes/parser/SanitizerTest.php @@ -1,9 +1,8 @@ assertEquals( - "\xc3\xa9cole", - Sanitizer::decodeCharReferences( 'école' ), - 'decode named entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeNumericEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole!", - Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), - 'decode numeric entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeMixedEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole!", - Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), - 'decode mixed numeric/named entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testDecodeMixedComplexEntities() { - $this->assertEquals( - "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas Ĉio dans l'école)", - Sanitizer::decodeCharReferences( - "Ĉio bonas dans l'école! (mais pas &#x108;io dans l'&eacute;cole)" - ), - 'decode mixed complex entities' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidAmpersand() { - $this->assertEquals( - 'a & b', - Sanitizer::decodeCharReferences( 'a & b' ), - 'Invalid ampersand' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidEntities() { - $this->assertEquals( - '&foo;', - Sanitizer::decodeCharReferences( '&foo;' ), - 'Invalid named entity' - ); - } - - /** - * @covers Sanitizer::decodeCharReferences - */ - public function testInvalidNumberedEntities() { - $this->assertEquals( - UtfNormal\Constants::UTF8_REPLACEMENT, - Sanitizer::decodeCharReferences( "�" ), - 'Invalid numbered entity' - ); - } - /** * @covers Sanitizer::removeHTMLtags * @dataProvider provideHtml5Tags @@ -170,85 +90,11 @@ class SanitizerTest extends MediaWikiTestCase { $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg ); } - /** - * @dataProvider provideTagAttributesToDecode - * @covers Sanitizer::decodeTagAttributes - */ - public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) { - $this->assertEquals( $expected, - Sanitizer::decodeTagAttributes( $attributes ), - $message - ); - } - - public static function provideTagAttributesToDecode() { - return [ - [ [ 'foo' => 'bar' ], 'foo=bar', 'Unquoted attribute' ], - [ [ 'עברית' => 'bar' ], 'עברית=bar', 'Non-Latin attribute' ], - [ [ '६' => 'bar' ], '६=bar', 'Devanagari number' ], - [ [ '搭𨋢' => 'bar' ], '搭𨋢=bar', 'Non-BMP character' ], - [ [], 'ńgh=bar', 'Combining accent is not allowed' ], - [ [ 'foo' => 'bar' ], ' foo = bar ', 'Spaced attribute' ], - [ [ 'foo' => 'bar' ], 'foo="bar"', 'Double-quoted attribute' ], - [ [ 'foo' => 'bar' ], 'foo=\'bar\'', 'Single-quoted attribute' ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ - [ 'foo' => 'bar', 'baz' => 'foo' ], - 'foo=\'bar\' baz="foo"', - 'Several attributes' - ], - [ [ ':foo' => 'bar' ], ':foo=\'bar\'', 'Leading :' ], - [ [ '_foo' => 'bar' ], '_foo=\'bar\'', 'Leading _' ], - [ [ 'foo' => 'bar' ], 'Foo=\'bar\'', 'Leading capital' ], - [ [ 'foo' => 'BAR' ], 'FOO=BAR', 'Attribute keys are normalized to lowercase' ], - - # Invalid beginning - [ [], '-foo=bar', 'Leading - is forbidden' ], - [ [], '.foo=bar', 'Leading . is forbidden' ], - [ [ 'foo-bar' => 'bar' ], 'foo-bar=bar', 'A - is allowed inside the attribute' ], - [ [ 'foo-' => 'bar' ], 'foo-=bar', 'A - is allowed inside the attribute' ], - [ [ 'foo.bar' => 'baz' ], 'foo.bar=baz', 'A . is allowed inside the attribute' ], - [ [ 'foo.' => 'baz' ], 'foo.=baz', 'A . is allowed as last character' ], - [ [ 'foo6' => 'baz' ], 'foo6=baz', 'Numbers are allowed' ], - - # This bit is more relaxed than XML rules, but some extensions use - # it, like ProofreadPage (see T29539) - [ [ '1foo' => 'baz' ], '1foo=baz', 'Leading numbers are allowed' ], - [ [], 'foo$=baz', 'Symbols are not allowed' ], - [ [], 'foo@=baz', 'Symbols are not allowed' ], - [ [], 'foo~=baz', 'Symbols are not allowed' ], - [ - [ 'foo' => '1[#^`*%w/(' ], - 'foo=1[#^`*%w/(', - 'All kind of characters are allowed as values' - ], - [ - [ 'foo' => '1[#^`*%\'w/(' ], - 'foo="1[#^`*%\'w/("', - 'Double quotes are allowed if quoted by single quotes' - ], - [ - [ 'foo' => '1[#^`*%"w/(' ], - 'foo=\'1[#^`*%"w/(\'', - 'Single quotes are allowed if quoted by double quotes' - ], - [ [ 'foo' => '&"' ], 'foo=&"', 'Special chars can be provided as entities' ], - [ [ 'foo' => '&foobar;' ], 'foo=&foobar;', 'Entity-like items are accepted' ], - ]; - } - /** * @dataProvider provideDeprecatedAttributes * @covers Sanitizer::fixTagAttributes + * @covers Sanitizer::validateTagAttributes + * @covers Sanitizer::validateAttributes */ public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) { $this->assertEquals( " $inputAttr", @@ -275,158 +121,55 @@ class SanitizerTest extends MediaWikiTestCase { } /** - * @dataProvider provideCssCommentsFixtures - * @covers Sanitizer::checkCss + * @dataProvider provideValidateTagAttributes + * @covers Sanitizer::validateTagAttributes + * @covers Sanitizer::validateAttributes */ - public function testCssCommentsChecking( $expected, $css, $message = '' ) { - $this->assertEquals( $expected, - Sanitizer::checkCss( $css ), - $message - ); + public function testValidateTagAttributes( $element, $attribs, $expected ) { + $actual = Sanitizer::validateTagAttributes( $attribs, $element ); + $this->assertArrayEquals( $expected, $actual, false, true ); } - public static function provideCssCommentsFixtures() { - /** [ , , [message] ] */ + public static function provideValidateTagAttributes() { return [ - // Valid comments spanning entire input - [ '/**/', '/**/' ], - [ '/* comment */', '/* comment */' ], - // Weird stuff - [ ' ', '/****/' ], - [ ' ', '/* /* */' ], - [ 'display: block;', "display:/* foo */block;" ], - [ 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;", - 'Backslash-escaped comments must be stripped (T30450)' ], - [ '', '/* unfinished comment structure', - 'Remove anything after a comment-start token' ], - [ '', "\\2f\\2a unifinished comment'", - 'Remove anything after a backslash-escaped comment-start token' ], - [ - '/* insecure input */', - 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader' - . '(src=\'asdf.png\',sizingMethod=\'scale\');' - ], - [ - '/* insecure input */', - '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader' - . '(src=\'asdf.png\',sizingMethod=\'scale\')";' - ], - [ '/* insecure input */', 'width: expression(1+1);' ], - [ '/* insecure input */', 'background-image: image(asdf.png);' ], - [ '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ], - [ '/* insecure input */', 'background-image: -moz-image(asdf.png);' ], - [ '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ], - [ - '/* insecure input */', - 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);' + [ 'math', + [ 'id' => 'foo bar', 'bogus' => 'stripped', 'data-foo' => 'bar' ], + [ 'id' => 'foo_bar', 'data-foo' => 'bar' ], ], - [ - '/* insecure input */', - 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' + [ 'meta', + [ 'id' => 'foo bar', 'itemprop' => 'foo', 'content' => 'bar' ], + [ 'itemprop' => 'foo', 'content' => 'bar' ], ], - [ '/* insecure input */', 'foo: attr( title, url );' ], - [ '/* insecure input */', 'foo: attr( title url );' ], - ]; - } - - /** - * @dataProvider provideEscapeHtmlAllowEntities - * @covers Sanitizer::escapeHtmlAllowEntities - */ - public function testEscapeHtmlAllowEntities( $expected, $html ) { - $this->assertEquals( - $expected, - Sanitizer::escapeHtmlAllowEntities( $html ) - ); - } - - public static function provideEscapeHtmlAllowEntities() { - return [ - [ 'foo', 'foo' ], - [ 'a¡b', 'a¡b' ], - [ 'foo'bar', "foo'bar" ], - [ '<script>foo</script>', '' ], - ]; - } - - /** - * Test Sanitizer::escapeId - * - * @dataProvider provideEscapeId - * @covers Sanitizer::escapeId - */ - public function testEscapeId( $input, $output ) { - $this->assertEquals( - $output, - Sanitizer::escapeId( $input, [ 'noninitial', 'legacy' ] ) - ); - } - - public static function provideEscapeId() { - return [ - [ '+', '.2B' ], - [ '&', '.26' ], - [ '=', '.3D' ], - [ ':', ':' ], - [ ';', '.3B' ], - [ '@', '.40' ], - [ '$', '.24' ], - [ '-_.', '-_.' ], - [ '!', '.21' ], - [ '*', '.2A' ], - [ '/', '.2F' ], - [ '[]', '.5B.5D' ], - [ '<>', '.3C.3E' ], - [ '\'', '.27' ], - [ '§', '.C2.A7' ], - [ 'Test:A & B/Here', 'Test:A_.26_B.2FHere' ], - [ 'A&B&C&amp;D&amp;amp;E', 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' ], ]; } /** - * Test escapeIdReferenceList for consistency with escapeIdForAttribute - * - * @dataProvider provideEscapeIdReferenceList - * @covers Sanitizer::escapeIdReferenceList + * @dataProvider provideAttributeWhitelist + * @covers Sanitizer::attributeWhitelist */ - public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) { - $this->assertEquals( - Sanitizer::escapeIdReferenceList( $referenceList ), - Sanitizer::escapeIdForAttribute( $id1 ) - . ' ' - . Sanitizer::escapeIdForAttribute( $id2 ) - ); - } - - public static function provideEscapeIdReferenceList() { - /** [ , , ] */ - return [ - [ 'foo bar', 'foo', 'bar' ], - [ '#1 #2', '#1', '#2' ], - [ '+1 +2', '+1', '+2' ], - ]; + public function testAttributeWhitelist( $element, $attribs ) { + $this->hideDeprecated( 'Sanitizer::attributeWhitelist' ); + $this->hideDeprecated( 'Sanitizer::setupAttributeWhitelist' ); + $actual = Sanitizer::attributeWhitelist( $element ); + $this->assertArrayEquals( $attribs, $actual ); } /** - * @dataProvider provideIsReservedDataAttribute - * @covers Sanitizer::isReservedDataAttribute + * @dataProvider provideAttributeWhitelist + * @covers Sanitizer::attributeWhitelistInternal */ - public function testIsReservedDataAttribute( $attr, $expected ) { - $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) ); + public function testAttributeWhitelistInternal( $element, $attribs ) { + $sanitizer = TestingAccessWrapper::newFromClass( Sanitizer::class ); + $actual = $sanitizer->attributeWhitelistInternal( $element ); + $this->assertArrayEquals( $attribs, array_keys( $actual ) ); } - public static function provideIsReservedDataAttribute() { + public function provideAttributeWhitelist() { + /** [ , [ , , ...] ] */ return [ - [ 'foo', false ], - [ 'data', false ], - [ 'data-foo', false ], - [ 'data-mw', true ], - [ 'data-ooui', true ], - [ 'data-parsoid', true ], - [ 'data-mw-foo', true ], - [ 'data-ooui-foo', true ], - [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently + [ 'math', [ 'class', 'style', 'id', 'title' ] ], + [ 'meta', [ 'itemprop', 'content' ] ], + [ 'link', [ 'itemprop', 'href', 'title' ] ], ]; } @@ -502,35 +245,6 @@ class SanitizerTest extends MediaWikiTestCase { ]; } - /** - * @dataProvider provideStripAllTags - * - * @covers Sanitizer::stripAllTags() - * @covers RemexStripTagHandler - * - * @param string $input - * @param string $expected - */ - public function testStripAllTags( $input, $expected ) { - $this->assertEquals( $expected, Sanitizer::stripAllTags( $input ) ); - } - - public function provideStripAllTags() { - return [ - [ '

    Foo

    ', 'Foo' ], - [ '

    Foo

    Bar

    ', 'Foo Bar' ], - [ "

    Foo

    \n

    Bar

    ", 'Foo Bar' ], - [ '

    Hello <strong> world café

    ', 'Hello world café' ], - [ - '

    quux\'>Bar Whee!

    ', - 'Bar Whee!' - ], - [ '123', '123' ], - [ '123', '123' ], - [ '12', '1 2' ], - ]; - } - /** * @expectedException InvalidArgumentException * @covers Sanitizer::escapeIdInternal() diff --git a/tests/phpunit/includes/parser/TidyTest.php b/tests/phpunit/includes/parser/TidyTest.php deleted file mode 100644 index 898ef2d163..0000000000 --- a/tests/phpunit/includes/parser/TidyTest.php +++ /dev/null @@ -1,64 +0,0 @@ -markTestSkipped( 'Tidy not found' ); - } - } - - /** - * @dataProvider provideTestWrapping - */ - public function testTidyWrapping( $expected, $text, $msg = '' ) { - $text = MWTidy::tidy( $text ); - // We don't care about where Tidy wants to stick is

    s - $text = trim( preg_replace( '##', '', $text ) ); - // Windows, we love you! - $text = str_replace( "\r", '', $text ); - $this->assertEquals( $expected, $text, $msg ); - } - - public static function provideTestWrapping() { - $testMathML = <<<'MathML' - - - a - - - x - 2 - - + - b - - x - + - c - - -MathML; - return [ - [ - 'foo', - 'foo', - ' should survive tidy' - ], - [ - 'foo', - 'foo', - ' should survive tidy' - ], - [ 'foo', 'foo', ' should survive tidy' ], - [ "foo", 'foo', ' should survive tidy' ], - [ "foo", 'foo', ' should survive tidy' ], - [ $testMathML, $testMathML, ' should survive tidy' ], - ]; - } -} diff --git a/tests/phpunit/includes/password/PasswordFactoryTest.php b/tests/phpunit/includes/password/PasswordFactoryTest.php deleted file mode 100644 index a7b3557516..0000000000 --- a/tests/phpunit/includes/password/PasswordFactoryTest.php +++ /dev/null @@ -1,124 +0,0 @@ -assertEquals( [ '' ], array_keys( $pf->getTypes() ) ); - $this->assertEquals( '', $pf->getDefaultType() ); - - $pf = new PasswordFactory( [ - 'foo' => [ 'class' => 'FooPassword' ], - 'bar' => [ 'class' => 'BarPassword', 'baz' => 'boom' ], - ], 'foo' ); - $this->assertEquals( [ '', 'foo', 'bar' ], array_keys( $pf->getTypes() ) ); - $this->assertArraySubset( [ 'class' => 'BarPassword', 'baz' => 'boom' ], $pf->getTypes()['bar'] ); - $this->assertEquals( 'foo', $pf->getDefaultType() ); - } - - public function testRegister() { - $pf = new PasswordFactory; - $pf->register( 'foo', [ 'class' => InvalidPassword::class ] ); - $this->assertArrayHasKey( 'foo', $pf->getTypes() ); - } - - public function testSetDefaultType() { - $pf = new PasswordFactory; - $pf->register( '1', [ 'class' => InvalidPassword::class ] ); - $pf->register( '2', [ 'class' => InvalidPassword::class ] ); - $pf->setDefaultType( '1' ); - $this->assertSame( '1', $pf->getDefaultType() ); - $pf->setDefaultType( '2' ); - $this->assertSame( '2', $pf->getDefaultType() ); - } - - /** - * @expectedException Exception - */ - public function testSetDefaultTypeError() { - $pf = new PasswordFactory; - $pf->setDefaultType( 'bogus' ); - } - - public function testInit() { - $config = new HashConfig( [ - 'PasswordConfig' => [ - 'foo' => [ 'class' => InvalidPassword::class ], - ], - 'PasswordDefault' => 'foo' - ] ); - $pf = new PasswordFactory; - $pf->init( $config ); - $this->assertSame( 'foo', $pf->getDefaultType() ); - $this->assertArrayHasKey( 'foo', $pf->getTypes() ); - } - - public function testNewFromCiphertext() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pw = $pf->newFromCiphertext( ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23' ); - $this->assertInstanceOf( MWSaltedPassword::class, $pw ); - } - - public function provideNewFromCiphertextErrors() { - return [ [ 'blah' ], [ ':blah:' ] ]; - } - - /** - * @dataProvider provideNewFromCiphertextErrors - * @expectedException PasswordError - */ - public function testNewFromCiphertextErrors( $hash ) { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->newFromCiphertext( $hash ); - } - - public function testNewFromType() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pw = $pf->newFromType( 'B' ); - $this->assertInstanceOf( MWSaltedPassword::class, $pw ); - } - - /** - * @expectedException PasswordError - */ - public function testNewFromTypeError() { - $pf = new PasswordFactory; - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->newFromType( 'bogus' ); - } - - public function testNewFromPlaintext() { - $pf = new PasswordFactory; - $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->setDefaultType( 'A' ); - - $this->assertInstanceOf( InvalidPassword::class, $pf->newFromPlaintext( null ) ); - $this->assertInstanceOf( MWOldPassword::class, $pf->newFromPlaintext( 'password' ) ); - $this->assertInstanceOf( MWSaltedPassword::class, - $pf->newFromPlaintext( 'password', $pf->newFromType( 'B' ) ) ); - } - - public function testNeedsUpdate() { - $pf = new PasswordFactory; - $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); - $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); - $pf->setDefaultType( 'A' ); - - $this->assertFalse( $pf->needsUpdate( $pf->newFromType( 'A' ) ) ); - $this->assertTrue( $pf->needsUpdate( $pf->newFromType( 'B' ) ) ); - } - - public function testGenerateRandomPasswordString() { - $this->assertSame( 13, strlen( PasswordFactory::generateRandomPasswordString( 13 ) ) ); - } - - public function testNewInvalidPassword() { - $this->assertInstanceOf( InvalidPassword::class, PasswordFactory::newInvalidPassword() ); - } -} diff --git a/tests/phpunit/includes/password/PasswordTest.php b/tests/phpunit/includes/password/PasswordTest.php deleted file mode 100644 index 61a5147277..0000000000 --- a/tests/phpunit/includes/password/PasswordTest.php +++ /dev/null @@ -1,33 +0,0 @@ -newFromPlaintext( null ); - - $this->assertInstanceOf( InvalidPassword::class, $invalid ); - } -} diff --git a/tests/phpunit/includes/poolcounter/PoolWorkArticleViewTest.php b/tests/phpunit/includes/poolcounter/PoolWorkArticleViewTest.php index 016a9a0b5b..c0544f4a0f 100644 --- a/tests/phpunit/includes/poolcounter/PoolWorkArticleViewTest.php +++ b/tests/phpunit/includes/poolcounter/PoolWorkArticleViewTest.php @@ -1,4 +1,5 @@ filterFromForm( '0' ) ); - self::assertSame( 3, $filter->filterFromForm( '3' ) ); - self::assertSame( '123', $filter->filterForForm( '123' ) ); - } - - /** - * @covers MediaWiki\Preferences\TimezoneFilter::filterFromForm() - * @dataProvider provideTimezoneFilter - * - * @param string $input - * @param string $expected - */ - public function testTimezoneFilter( $input, $expected ) { - $filter = new TimezoneFilter(); - $result = $filter->filterFromForm( $input ); - self::assertEquals( $expected, $result ); - } - - public function provideTimezoneFilter() { - return [ - [ 'ZoneInfo', 'Offset|0' ], - [ 'ZoneInfo|bogus', 'Offset|0' ], - [ 'System', 'System' ], - [ '2:30', 'Offset|150' ], - ]; - } - - /** - * @covers MediaWiki\Preferences\MultiUsernameFilter::filterFromForm() - * @dataProvider provideMultiUsernameFilterFrom - * - * @param string $input - * @param string|null $expected - */ - public function testMultiUsernameFilterFrom( $input, $expected ) { - $filter = $this->makeMultiUsernameFilter(); - $result = $filter->filterFromForm( $input ); - self::assertSame( $expected, $result ); - } - - public function provideMultiUsernameFilterFrom() { - return [ - [ '', null ], - [ "\n\n\n", null ], - [ 'Foo', '1' ], - [ "\n\n\nFoo\nBar\n", "1\n2" ], - [ "Baz\nInvalid\nFoo", "3\n1" ], - [ "Invalid", null ], - [ "Invalid\n\n\nInvalid\n", null ], - ]; - } - - /** - * @covers MediaWiki\Preferences\MultiUsernameFilter::filterForForm() - * @dataProvider provideMultiUsernameFilterFor - * - * @param string $input - * @param string $expected - */ - public function testMultiUsernameFilterFor( $input, $expected ) { - $filter = $this->makeMultiUsernameFilter(); - $result = $filter->filterForForm( $input ); - self::assertSame( $expected, $result ); - } - - public function provideMultiUsernameFilterFor() { - return [ - [ '', '' ], - [ "\n", '' ], - [ '1', 'Foo' ], - [ "\n1\n\n2\377\n", "Foo\nBar" ], - [ "666\n667", '' ], - ]; - } - - private function makeMultiUsernameFilter() { - $userMapping = [ - 'Foo' => 1, - 'Bar' => 2, - 'Baz' => 3, - ]; - $flipped = array_flip( $userMapping ); - $idLookup = self::getMockBuilder( CentralIdLookup::class ) - ->disableOriginalConstructor() - ->setMethods( [ 'centralIdsFromNames', 'namesFromCentralIds' ] ) - ->getMockForAbstractClass(); - - $idLookup->method( 'centralIdsFromNames' ) - ->will( self::returnCallback( function ( $names ) use ( $userMapping ) { - $ids = []; - foreach ( $names as $name ) { - $ids[] = $userMapping[$name] ?? null; - } - return array_filter( $ids, 'is_numeric' ); - } ) ); - $idLookup->method( 'namesFromCentralIds' ) - ->will( self::returnCallback( function ( $ids ) use ( $flipped ) { - $names = []; - foreach ( $ids as $id ) { - $names[] = $flipped[$id] ?? null; - } - return array_filter( $names, 'is_string' ); - } ) ); - - return new MultiUsernameFilter( $idLookup ); - } -} diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php deleted file mode 100644 index d5a2b3a5a7..0000000000 --- a/tests/phpunit/includes/registration/ExtensionProcessorTest.php +++ /dev/null @@ -1,773 +0,0 @@ -dir = __DIR__ . '/FooBar/extension.json'; - $this->dirname = dirname( $this->dir ); - } - - /** - * 'name' is absolutely required - * - * @var array - */ - public static $default = [ - 'name' => 'FooBar', - ]; - - public function testExtractInfo() { - // Test that attributes that begin with @ are ignored - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default + [ - '@metadata' => [ 'foobarbaz' ], - 'AnAttribute' => [ 'omg' ], - 'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ], - 'SpecialPages' => [ 'Foo' => 'SpecialFoo' ], - 'callback' => 'FooBar::onRegistration', - ], 1 ); - - $extracted = $processor->getExtractedInfo(); - $attributes = $extracted['attributes']; - $this->assertArrayHasKey( 'AnAttribute', $attributes ); - $this->assertArrayNotHasKey( '@metadata', $attributes ); - $this->assertArrayNotHasKey( 'AutoloadClasses', $attributes ); - $this->assertSame( - [ 'FooBar' => 'FooBar::onRegistration' ], - $extracted['callbacks'] - ); - $this->assertSame( - [ 'Foo' => 'SpecialFoo' ], - $extracted['globals']['wgSpecialPages'] - ); - } - - public function testExtractNamespaces() { - // Test that namespace IDs can be overwritten - if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) { - define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 ); - } - - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default + [ - 'namespaces' => [ - [ - 'id' => 332200, - 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', - 'name' => 'Test_A', - 'defaultcontentmodel' => 'TestModel', - 'gender' => [ - 'male' => 'Male test', - 'female' => 'Female test', - ], - 'subpages' => true, - 'content' => true, - 'protection' => 'userright', - ], - [ // Test_X will use ID 123456 not 334400 - 'id' => 334400, - 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', - 'name' => 'Test_X', - 'defaultcontentmodel' => 'TestModel' - ], - ] - ], 1 ); - - $extracted = $processor->getExtractedInfo(); - - $this->assertArrayHasKey( - 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', - $extracted['defines'] - ); - $this->assertArrayNotHasKey( - 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', - $extracted['defines'] - ); - - $this->assertSame( - $extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A'], - 332200 - ); - - $this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] ); - $this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] ); - $this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] ); - $this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] ); - - $this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] ); - $this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] ); - $this->assertSame( - [ 'male' => 'Male test', 'female' => 'Female test' ], - $extracted['globals']['wgExtraGenderNamespaces'][332200] - ); - // A has subpages, X does not - $this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] ); - $this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] ); - } - - public static function provideRegisterHooks() { - $merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ]; - // Format: - // Current $wgHooks - // Content in extension.json - // Expected value of $wgHooks - return [ - // No hooks - [ - [], - self::$default, - $merge, - ], - // No current hooks, adding one for "FooBaz" in string format - [ - [], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, - ], - // Hook for "FooBaz", adding another one - [ - [ 'FooBaz' => [ 'PriorCallback' ] ], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge, - ], - // No current hooks, adding one for "FooBaz" in verbose array format - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default, - [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, - ], - // Hook for "BarBaz", adding one for "FooBaz" - [ - [ 'BarBaz' => [ 'BarBazCallback' ] ], - [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, - [ - 'BarBaz' => [ 'BarBazCallback' ], - 'FooBaz' => [ 'FooBazCallback' ], - ] + $merge, - ], - // Callbacks for FooBaz wrapped in an array - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default, - [ - 'FooBaz' => [ 'Callback1' ], - ] + $merge, - ], - // Multiple callbacks for FooBaz hook - [ - [], - [ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default, - [ - 'FooBaz' => [ 'Callback1', 'Callback2' ], - ] + $merge, - ], - ]; - } - - /** - * @dataProvider provideRegisterHooks - */ - public function testRegisterHooks( $pre, $info, $expected ) { - $processor = new MockExtensionProcessor( [ 'wgHooks' => $pre ] ); - $processor->extractInfo( $this->dir, $info, 1 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( $expected, $extracted['globals']['wgHooks'] ); - } - - public function testExtractConfig1() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => 'somevalue', - 'Foo' => 10, - '@IGNORED' => 'yes', - ], - ] + self::$default; - $info2 = [ - 'config' => [ - '_prefix' => 'eg', - 'Bar' => 'somevalue' - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 1 ); - $processor->extractInfo( $this->dir, $info2, 1 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); - $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); - $this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] ); - // Custom prefix: - $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); - } - - public function testExtractConfig2() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - 'Foo' => [ 'value' => 10 ], - 'Path' => [ 'value' => 'foo.txt', 'path' => true ], - 'Namespaces' => [ - 'value' => [ - '10' => true, - '12' => false, - ], - 'merge_strategy' => 'array_plus', - ], - ], - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ], - 'config_prefix' => 'eg', - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 2 ); - $processor->extractInfo( $this->dir, $info2, 2 ); - $extracted = $processor->getExtractedInfo(); - $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); - $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); - $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] ); - // Custom prefix: - $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); - $this->assertSame( - [ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ], - $extracted['globals']['wgNamespaces'] - ); - } - - /** - * @expectedException RuntimeException - */ - public function testDuplicateConfigKey1() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => '', - ] - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => 'g', - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 1 ); - $processor->extractInfo( $this->dir, $info2, 1 ); - } - - /** - * @expectedException RuntimeException - */ - public function testDuplicateConfigKey2() { - $processor = new ExtensionProcessor; - $info = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ] - ] + self::$default; - $info2 = [ - 'config' => [ - 'Bar' => [ 'value' => 'somevalue' ], - ], - 'name' => 'FooBar2', - ]; - $processor->extractInfo( $this->dir, $info, 2 ); - $processor->extractInfo( $this->dir, $info2, 2 ); - } - - public static function provideExtractExtensionMessagesFiles() { - $dir = __DIR__ . '/FooBar/'; - return [ - [ - [ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ], - [ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ] - ], - [ - [ - 'ExtensionMessagesFiles' => [ - 'FooBarAlias' => 'FooBar.alias.php', - 'FooBarMagic' => 'FooBar.magic.i18n.php', - ], - ], - [ - 'wgExtensionMessagesFiles' => [ - 'FooBarAlias' => $dir . 'FooBar.alias.php', - 'FooBarMagic' => $dir . 'FooBar.magic.i18n.php', - ], - ], - ], - ]; - } - - /** - * @dataProvider provideExtractExtensionMessagesFiles - */ - public function testExtractExtensionMessagesFiles( $input, $expected ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expected as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - } - - public static function provideExtractMessagesDirs() { - $dir = __DIR__ . '/FooBar/'; - return [ - [ - [ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ], - [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ] - ], - [ - [ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ], - [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ] - ], - ]; - } - - /** - * @dataProvider provideExtractMessagesDirs - */ - public function testExtractMessagesDirs( $input, $expected ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expected as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - } - - public function testExtractCredits() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, self::$default, 1 ); - $this->setExpectedException( Exception::class ); - $processor->extractInfo( $this->dir, self::$default, 1 ); - } - - /** - * @dataProvider provideExtractResourceLoaderModules - */ - public function testExtractResourceLoaderModules( - $input, - array $expectedGlobals, - array $expectedAttribs = [] - ) { - $processor = new ExtensionProcessor(); - $processor->extractInfo( $this->dir, $input + self::$default, 1 ); - $out = $processor->getExtractedInfo(); - foreach ( $expectedGlobals as $key => $value ) { - $this->assertEquals( $value, $out['globals'][$key] ); - } - foreach ( $expectedAttribs as $key => $value ) { - $this->assertEquals( $value, $out['attributes'][$key] ); - } - } - - public static function provideExtractResourceLoaderModules() { - $dir = __DIR__ . '/FooBar'; - return [ - // Generic module with localBasePath/remoteExtPath specified - [ - // Input - [ - 'ResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foobar.js', - 'localBasePath' => '', - 'remoteExtPath' => 'FooBar', - ], - ], - ], - // Expected - [ - 'wgResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foobar.js', - 'localBasePath' => $dir, - 'remoteExtPath' => 'FooBar', - ], - ], - ], - ], - // ResourceFileModulePaths specified: - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => 'modules', - 'remoteExtPath' => 'FooBar/modules', - ], - 'ResourceModules' => [ - // No paths - 'test.foo' => [ - 'styles' => 'foo.js', - ], - // Different paths set - 'test.bar' => [ - 'styles' => 'bar.js', - 'localBasePath' => 'subdir', - 'remoteExtPath' => 'FooBar/subdir', - ], - // Custom class with no paths set - 'test.class' => [ - 'class' => 'FooBarModule', - 'extra' => 'argument', - ], - // Custom class with a localBasePath - 'test.class.with.path' => [ - 'class' => 'FooBarPathModule', - 'extra' => 'argument', - 'localBasePath' => '', - ] - ], - ], - // Expected - [ - 'wgResourceModules' => [ - 'test.foo' => [ - 'styles' => 'foo.js', - 'localBasePath' => "$dir/modules", - 'remoteExtPath' => 'FooBar/modules', - ], - 'test.bar' => [ - 'styles' => 'bar.js', - 'localBasePath' => "$dir/subdir", - 'remoteExtPath' => 'FooBar/subdir', - ], - 'test.class' => [ - 'class' => 'FooBarModule', - 'extra' => 'argument', - 'localBasePath' => "$dir/modules", - 'remoteExtPath' => 'FooBar/modules', - ], - 'test.class.with.path' => [ - 'class' => 'FooBarPathModule', - 'extra' => 'argument', - 'localBasePath' => $dir, - 'remoteExtPath' => 'FooBar/modules', - ] - ], - ], - ], - // ResourceModuleSkinStyles with file module paths - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => '', - 'remoteSkinPath' => 'FooBar', - ], - 'ResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - ] - ], - ], - // Expected - [ - 'wgResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'localBasePath' => $dir, - 'remoteSkinPath' => 'FooBar', - ], - ], - ], - ], - // ResourceModuleSkinStyles with file module paths and an override - [ - // Input - [ - 'ResourceFileModulePaths' => [ - 'localBasePath' => '', - 'remoteSkinPath' => 'FooBar', - ], - 'ResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'remoteSkinPath' => 'BarFoo' - ], - ], - ], - // Expected - [ - 'wgResourceModuleSkinStyles' => [ - 'foobar' => [ - 'test.foo' => 'foo.css', - 'localBasePath' => $dir, - 'remoteSkinPath' => 'BarFoo', - ], - ], - ], - ], - 'QUnit test module' => [ - // Input - [ - 'QUnitTestModule' => [ - 'localBasePath' => '', - 'remoteExtPath' => 'Foo', - 'scripts' => 'bar.js', - ], - ], - // Expected - [], - [ - 'QUnitTestModules' => [ - 'test.FooBar' => [ - 'localBasePath' => $dir, - 'remoteExtPath' => 'Foo', - 'scripts' => 'bar.js', - ], - ], - ], - ], - ]; - } - - public static function provideSetToGlobal() { - return [ - [ - [ 'wgAPIModules', 'wgAvailableRights' ], - [], - [ - 'APIModules' => [ 'foobar' => 'ApiFooBar' ], - 'AvailableRights' => [ 'foobar', 'unfoobar' ], - ], - [ - 'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ], - 'wgAvailableRights' => [ 'foobar', 'unfoobar' ], - ], - ], - [ - [ 'wgAPIModules', 'wgAvailableRights' ], - [ - 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ], - 'wgAvailableRights' => [ 'barbaz' ] - ], - [ - 'APIModules' => [ 'foobar' => 'ApiFooBar' ], - 'AvailableRights' => [ 'foobar', 'unfoobar' ], - ], - [ - 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ], - 'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ], - ], - ], - [ - [ 'wgGroupPermissions' ], - [ - 'wgGroupPermissions' => [ - 'sysop' => [ 'delete' ] - ], - ], - [ - 'GroupPermissions' => [ - 'sysop' => [ 'undelete' ], - 'user' => [ 'edit' ] - ], - ], - [ - 'wgGroupPermissions' => [ - 'sysop' => [ 'delete', 'undelete' ], - 'user' => [ 'edit' ] - ], - ] - ] - ]; - } - - /** - * Attributes under manifest_version 2 - */ - public function testExtractAttributes() { - $processor = new ExtensionProcessor(); - // Load FooBar extension - $processor->extractInfo( $this->dir, [ 'name' => 'FooBar' ], 2 ); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'Baz', - 'attributes' => [ - // Loaded - 'FooBar' => [ - 'Plugins' => [ - 'ext.baz.foobar', - ], - ], - // Not loaded - 'FizzBuzz' => [ - 'MorePlugins' => [ - 'ext.baz.fizzbuzz', - ], - ], - ], - ], - 2 - ); - - $info = $processor->getExtractedInfo(); - $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); - $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); - $this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); - } - - /** - * Attributes under manifest_version 1 - */ - public function testAttributes1() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'FooBar', - 'FooBarPlugins' => [ - 'ext.baz.foobar', - ], - 'FizzBuzzMorePlugins' => [ - 'ext.baz.fizzbuzz', - ], - ], - 1 - ); - $processor->extractInfo( - $this->dir, - [ - 'name' => 'FooBar2', - 'FizzBuzzMorePlugins' => [ - 'ext.bar.fizzbuzz', - ] - ], - 1 - ); - - $info = $processor->getExtractedInfo(); - $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); - $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); - $this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); - $this->assertSame( - [ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ], - $info['attributes']['FizzBuzzMorePlugins'] - ); - } - - public function testAttributes1_notarray() { - $processor = new ExtensionProcessor(); - $this->setExpectedException( - InvalidArgumentException::class, - "The value for 'FooBarPlugins' should be an array (from {$this->dir})" - ); - $processor->extractInfo( - $this->dir, - [ - 'FooBarPlugins' => 'ext.baz.foobar', - ] + self::$default, - 1 - ); - } - - public function testExtractPathBasedGlobal() { - $processor = new ExtensionProcessor(); - $processor->extractInfo( - $this->dir, - [ - 'ParserTestFiles' => [ - 'tests/parserTests.txt', - 'tests/extraParserTests.txt', - ], - 'ServiceWiringFiles' => [ - 'includes/ServiceWiring.php' - ], - ] + self::$default, - 1 - ); - $globals = $processor->getExtractedInfo()['globals']; - $this->assertArrayHasKey( 'wgParserTestFiles', $globals ); - $this->assertSame( [ - "{$this->dirname}/tests/parserTests.txt", - "{$this->dirname}/tests/extraParserTests.txt" - ], $globals['wgParserTestFiles'] ); - $this->assertArrayHasKey( 'wgServiceWiringFiles', $globals ); - $this->assertSame( [ - "{$this->dirname}/includes/ServiceWiring.php" - ], $globals['wgServiceWiringFiles'] ); - } - - public function testGetRequirements() { - $info = self::$default + [ - 'requires' => [ - 'MediaWiki' => '>= 1.25.0', - 'platform' => [ - 'php' => '>= 5.5.9' - ], - 'extensions' => [ - 'Bar' => '*' - ] - ] - ]; - $processor = new ExtensionProcessor(); - $this->assertSame( - $info['requires'], - $processor->getRequirements( $info ) - ); - $this->assertSame( - [], - $processor->getRequirements( [] ) - ); - } - - public function testGetExtraAutoloaderPaths() { - $processor = new ExtensionProcessor(); - $this->assertSame( - [ "{$this->dirname}/vendor/autoload.php" ], - $processor->getExtraAutoloaderPaths( $this->dirname, [ - 'load_composer_autoloader' => true, - ] ) - ); - } - - /** - * Verify that extension.schema.json is in sync with ExtensionProcessor - * - * @coversNothing - */ - public function testGlobalSettingsDocumentedInSchema() { - global $IP; - $globalSettings = TestingAccessWrapper::newFromClass( - ExtensionProcessor::class )->globalSettings; - - $version = ExtensionRegistry::MANIFEST_VERSION; - $schema = FormatJson::decode( - file_get_contents( "$IP/docs/extension.schema.v$version.json" ), - true - ); - $missing = []; - foreach ( $globalSettings as $global ) { - if ( !isset( $schema['properties'][$global] ) ) { - $missing[] = $global; - } - } - - $this->assertEquals( [], $missing, - "The following global settings are not documented in docs/extension.schema.json" ); - } -} - -/** - * Allow overriding the default value of $this->globals - * so we can test merging - */ -class MockExtensionProcessor extends ExtensionProcessor { - public function __construct( $globals = [] ) { - $this->globals = $globals + $this->globals; - } -} diff --git a/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php index c210061191..e178e96f41 100644 --- a/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php +++ b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php @@ -16,7 +16,10 @@ class DerivativeResourceLoaderContextTest extends PHPUnit\Framework\TestCase { 'skin' => 'fallback', 'target' => 'test', ] ); - return new ResourceLoaderContext( new ResourceLoader(), $request ); + return new ResourceLoaderContext( + new ResourceLoader( ResourceLoaderTestCase::getMinimalConfig() ), + $request + ); } public function testChangeModules() { @@ -30,6 +33,7 @@ class DerivativeResourceLoaderContextTest extends PHPUnit\Framework\TestCase { public function testChangeLanguageAndDirection() { $derived = new DerivativeResourceLoaderContext( self::makeContext() ); $this->assertSame( $derived->getLanguage(), 'qqx', 'inherit from parent' ); + $this->assertSame( $derived->getDirection(), 'ltr', 'inherit from parent' ); $derived->setLanguage( 'nl' ); $this->assertSame( $derived->getLanguage(), 'nl' ); diff --git a/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php index 9afa232bd4..e094d92b9d 100644 --- a/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php +++ b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php @@ -25,7 +25,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testBlobCreation() { $module = $this->makeModule( [ 'mainpage' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( null, $rl ); @@ -36,7 +36,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testBlobCreation_empty() { $module = $this->makeModule( [] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( null, $rl ); @@ -47,7 +47,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testBlobCreation_unknownMessage() { $module = $this->makeModule( [ 'i-dont-exist', 'mainpage', 'i-dont-exist2' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( null, $rl ); @@ -59,7 +59,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testMessageCachingAndPurging() { $module = $this->makeModule( [ 'example' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); @@ -104,7 +104,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testPurgeEverything() { $module = $this->makeModule( [ 'example' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); // Advance this new WANObjectCache instance to a normal state. @@ -138,7 +138,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { public function testValidateAgainstModuleRegistry() { // Arrange version 1 of a module $module = $this->makeModule( [ 'foo' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); $blobStore->expects( $this->once() ) @@ -157,7 +157,7 @@ class MessageBlobStoreTest extends PHPUnit\Framework\TestCase { // must always match the set of message keys required by the module. // We do not receive purges for this because no messages were changed. $module = $this->makeModule( [ 'foo', 'bar' ] ); - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $rl->register( $module->getName(), $module ); $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); $blobStore->expects( $this->exactly( 2 ) ) diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php index 206160c7cf..408a0a24cc 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -116,9 +116,9 @@ Deprecation message.' ] . '' . "\n" - . '' . "\n" + . '' . "\n" . '' . "\n" - . ''; + . ''; // phpcs:enable $expected = self::expandVariables( $expected ); @@ -136,7 +136,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -153,7 +153,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -170,7 +170,7 @@ Deprecation message.' ] // phpcs:disable Generic.Files.LineLength $expected = '' . "\n" - . ''; + . ''; // phpcs:enable $this->assertSame( $expected, (string)$client->getHeadHtml() ); @@ -224,54 +224,54 @@ Deprecation message.' ] ], [ 'context' => [], - // Eg. startup module - 'modules' => [ 'test.scripts.raw' ], + 'modules' => [ 'test.scripts' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, - 'extra' => [], - 'output' => '', + // Eg. startup module + 'extra' => [ 'raw' => '1' ], + 'output' => '', ], [ 'context' => [], - 'modules' => [ 'test.scripts.raw' ], + 'modules' => [ 'test.scripts' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, - 'extra' => [ 'sync' => '1' ], - 'output' => '', + 'extra' => [ 'raw' => '1', 'sync' => '1' ], + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.scripts.user' ], 'only' => ResourceLoaderModule::TYPE_SCRIPTS, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.user' ], 'only' => ResourceLoaderModule::TYPE_COMBINED, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [ 'debug' => 'true' ], 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '' . "\n" - . '', + 'output' => '' . "\n" + . '', ], [ 'context' => [ 'debug' => 'false' ], 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], 'modules' => [ 'test.styles.noscript' ], 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], @@ -299,7 +299,7 @@ Deprecation message.' ] 'modules' => [ 'test', 'test.shouldembed' ], 'only' => ResourceLoaderModule::TYPE_COMBINED, 'extra' => [], - 'output' => '', + 'output' => '', ], [ 'context' => [], @@ -307,7 +307,7 @@ Deprecation message.' ] 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], 'output' => - '' . "\n" + '' . "\n" . '' ], [ @@ -316,9 +316,9 @@ Deprecation message.' ] 'only' => ResourceLoaderModule::TYPE_STYLES, 'extra' => [], 'output' => - '' . "\n" + '' . "\n" . '' . "\n" - . '' + . '' ], ]; // phpcs:enable @@ -418,7 +418,6 @@ Deprecation message.' ] 'test.scripts' => [], 'test.scripts.user' => [ 'group' => 'user' ], 'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ], - 'test.scripts.raw' => [ 'isRaw' => true ], 'test.scripts.shouldembed' => [ 'shouldEmbed' => true ], 'test.ordering.a' => [ 'shouldEmbed' => false ], diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php index 60cd4a8778..c3d5ec1fed 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php @@ -15,6 +15,8 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { return new EmptyResourceLoader( new HashConfig( [ 'ResourceLoaderDebug' => false, 'LoadScript' => '/w/load.php', + // For ResourceLoader::register() + 'ResourceModuleSkinStyles' => [], ] ) ); } @@ -45,8 +47,10 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { public function testAccessors() { $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertInstanceOf( ResourceLoader::class, $ctx->getResourceLoader() ); + $this->assertInstanceOf( Config::class, $ctx->getConfig() ); $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() ); - $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() ); + $this->assertInstanceOf( Psr\Log\LoggerInterface::class, $ctx->getLogger() ); } public function testTypicalRequest() { @@ -74,6 +78,38 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() ); } + public static function provideDirection() { + yield 'LTR language' => [ + [ 'lang' => 'en' ], + 'ltr', + ]; + yield 'RTL language' => [ + [ 'lang' => 'he' ], + 'rtl', + ]; + yield 'explicit LTR' => [ + [ 'lang' => 'he', 'dir' => 'ltr' ], + 'ltr', + ]; + yield 'explicit RTL' => [ + [ 'lang' => 'en', 'dir' => 'rtl' ], + 'rtl', + ]; + // Not supported, but tested to cover the case and detect change + yield 'invalid dir' => [ + [ 'lang' => 'he', 'dir' => 'xyz' ], + 'rtl', + ]; + } + + /** + * @dataProvider provideDirection + */ + public function testDirection( array $params, $expected ) { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( $params ) ); + $this->assertEquals( $expected, $ctx->getDirection() ); + } + public function testShouldInclude() { $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php index 0a4cf1e108..5be0f9b79f 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php @@ -1,7 +1,6 @@ register( + $skinFactory->register( 'fakeskin', 'FakeSkin', function () { } ); + $this->setService( 'SkinFactory', $skinFactory ); + + // This test is not expected to query any database + MediaWiki\MediaWikiServices::disableStorageBackend(); } private static function getModules() { $base = [ - 'localBasePath' => realpath( __DIR__ ), + 'localBasePath' => __DIR__, ]; return [ @@ -227,12 +231,12 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testMixedCssAnnotations() { $basePath = __DIR__ . '/../../data/css'; - $testModule = new ResourceLoaderFileModule( [ + $testModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'test.css' ], ] ); $testModule->setName( 'testing' ); - $expectedModule = new ResourceLoaderFileModule( [ + $expectedModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'expected.css' ], ] ); @@ -317,7 +321,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { */ public function testBomConcatenation() { $basePath = __DIR__ . '/../../data/css'; - $testModule = new ResourceLoaderFileModule( [ + $testModule = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'bom.css' ], ] ); @@ -345,7 +349,6 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { $module = new ResourceLoaderFileTestModule( [ 'localBasePath' => $basePath, 'styles' => [ 'styles.less' ], - ], [ 'lessVars' => [ 'foo' => '2px', 'Foo' => '#eeeeee' ] ] ); $module->setName( 'test.less' ); @@ -353,27 +356,110 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); } + public function provideGetVersionHash() { + $a = []; + $b = [ + 'lessVars' => [ 'key' => 'value' ], + ]; + yield 'with and without Less variables' => [ $a, $b, false ]; + + $a = [ + 'lessVars' => [ 'key' => 'value1' ], + ]; + $b = [ + 'lessVars' => [ 'key' => 'value2' ], + ]; + yield 'different Less variables' => [ $a, $b, false ]; + + $x = [ + 'lessVars' => [ 'key' => 'value' ], + ]; + yield 'identical Less variables' => [ $x, $x, true ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () { + return [ 'aaa' ]; + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () { + return [ 'bbb' ]; + } ] ] + ]; + yield 'packageFiles with different callback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'aaa.json', 'callback' => function () { + return [ 'x' ]; + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'bbb.json', 'callback' => function () { + return [ 'x' ]; + } ] ] + ]; + yield 'packageFiles with different file name and a callback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () { + return [ 'A-version' ]; + }, 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () { + return [ 'B-version' ]; + }, 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } ] ] + ]; + yield 'packageFiles with different versionCallback' => [ $a, $b, false ]; + + $a = [ + 'packageFiles' => [ [ 'name' => 'aaa.json', + 'versionCallback' => function () { + return [ 'X-version' ]; + }, + 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } + ] ] + ]; + $b = [ + 'packageFiles' => [ [ 'name' => 'bbb.json', + 'versionCallback' => function () { + return [ 'X-version' ]; + }, + 'callback' => function () { + throw new Exception( 'Unexpected computation' ); + } + ] ] + ]; + yield 'packageFiles with different file name and a versionCallback' => [ $a, $b, false ]; + } + /** + * @dataProvider provideGetVersionHash * @covers ResourceLoaderFileModule::getDefinitionSummary * @covers ResourceLoaderFileModule::getFileHashes */ - public function testGetVersionHash() { + public function testGetVersionHash( $a, $b, $isEqual ) { $context = $this->getResourceLoaderContext(); - // Less variables - $module = new ResourceLoaderFileTestModule(); - $version = $module->getVersionHash( $context ); - $module = new ResourceLoaderFileTestModule( [], [ - 'lessVars' => [ 'key' => 'value' ], - ] ); - $this->assertNotEquals( - $version, - $module->getVersionHash( $context ), - 'Using less variables is significant' + $moduleA = new ResourceLoaderFileTestModule( $a ); + $versionA = $moduleA->getVersionHash( $context ); + $moduleB = new ResourceLoaderFileTestModule( $b ); + $versionB = $moduleB->getVersionHash( $context ); + + $this->assertSame( + $isEqual, + ( $versionA === $versionB ), + 'Whether versions hashes are equal' ); } - public function providerGetScriptPackageFiles() { + public function provideGetScriptPackageFiles() { $basePath = __DIR__ . '/../../data/resourceloader'; $base = [ 'localBasePath' => $basePath ]; $commentScript = file_get_contents( "$basePath/script-comment.js" ); @@ -449,7 +535,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { 'main' => 'init.js' ] ], - [ + 'package file with callback' => [ $base + [ 'packageFiles' => [ [ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ] ], @@ -496,6 +582,34 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { 'lang' => 'fy' ] ], + 'package file with callback and versionCallback' => [ + $base + [ + 'packageFiles' => [ + [ 'name' => 'bar.js', 'content' => "console.log('Hello');" ], + [ 'name' => 'data.json', 'versionCallback' => function ( $context ) { + return $context->getLanguage(); + }, 'callback' => function ( $context ) { + return [ 'langCode' => $context->getLanguage() ]; + } ], + ] + ], + [ + 'files' => [ + 'bar.js' => [ + 'type' => 'script', + 'content' => "console.log('Hello');", + ], + 'data.json' => [ + 'type' => 'data', + 'content' => [ 'langCode' => 'fy' ] + ], + ], + 'main' => 'bar.js' + ], + [ + 'lang' => 'fy' + ] + ], [ $base + [ 'packageFiles' => [ @@ -504,7 +618,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { ], false ], - [ + 'package file with invalid callback' => [ $base + [ 'packageFiles' => [ [ 'name' => 'foo.json', 'callback' => 'functionThatDoesNotExist142857' ] @@ -557,7 +671,7 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase { } /** - * @dataProvider providerGetScriptPackageFiles + * @dataProvider provideGetScriptPackageFiles * @covers ResourceLoaderFileModule::getScript * @covers ResourceLoaderFileModule::getPackageFiles * @covers ResourceLoaderFileModule::expandPackageFiles diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php index 35c3ef6454..c3fc55acc1 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php @@ -62,7 +62,6 @@ class ResourceLoaderImageTest extends ResourceLoaderTestCase { 'he' => 'rtl', 'ar' => 'rtl', ]; - static $contexts = []; $image = $this->getTestImage( $imageName ); $context = $this->getResourceLoaderContext( [ @@ -129,6 +128,7 @@ class ResourceLoaderImageTestable extends ResourceLoaderImage { public function massageSvgPathdata( $svg ) { return parent::massageSvgPathdata( $svg ); } + // Stub, since we don't know if we even have a SVG handler, much less what exactly it'll output public function rasterize( $svg ) { return 'RASTERIZESTUB'; diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php index 0c707d5537..3f6e9b00e4 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php @@ -56,7 +56,7 @@ class ResourceLoaderModuleTest extends ResourceLoaderTestCase { ); // Subclass - $module = new ResourceLoaderFileModuleTestModule( $baseParams ); + $module = new ResourceLoaderFileModuleTestingSubclass( $baseParams ); $this->assertNotEquals( $version, json_encode( $module->getVersionHash( $context ) ), diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php index 30e9f300c1..5df52bc422 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php @@ -16,26 +16,18 @@ class ResourceLoaderOOUIImageModuleTest extends ResourceLoaderTestCase { ] ); // Pretend that 'fakemonobook' is a real skin using the Apex theme - SkinFactory::getDefaultInstance()->register( + $skinFactory = new SkinFactory(); + $skinFactory->register( 'fakemonobook', 'FakeMonoBook', function () { } ); - $r = new ReflectionMethod( ExtensionRegistry::class, 'exportExtractedData' ); - $r->setAccessible( true ); - $r->invoke( ExtensionRegistry::getInstance(), [ - 'globals' => [], - 'defines' => [], - 'callbacks' => [], - 'credits' => [], - 'autoloaderPaths' => [], - 'attributes' => [ - 'SkinOOUIThemes' => [ - 'fakemonobook' => 'Apex', - ], - ], - ] ); + $this->setService( 'SkinFactory', $skinFactory ); + + $reset = ExtensionRegistry::getInstance()->setAttributeForTest( + 'SkinOOUIThemes', [ 'fakemonobook' => 'Apex' ] + ); $styles = $module->getStyles( $this->getResourceLoaderContext( [ 'skin' => 'fakemonobook' ] ) ); $this->assertRegExp( diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php index 231979d689..23b0cb911b 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php @@ -7,8 +7,6 @@ use Wikimedia\TestingAccessWrapper; */ class ResourceLoaderSkinModuleTest extends MediaWikiTestCase { - use MediaWikiCoversValidator; - public static function provideGetStyles() { // phpcs:disable Generic.Files.LineLength return [ diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php index d4b5ed6c91..bc7cb6924c 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php @@ -36,10 +36,12 @@ mw.loader.register( [ ] );', ] ], [ [ - 'msg' => 'Omit raw modules from registry', + 'msg' => 'Optimise the dependency tree (basic case)', 'modules' => [ - 'test.raw' => new ResourceLoaderTestModule( [ 'isRaw' => true ] ), - 'test.blank' => new ResourceLoaderTestModule(), + 'a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'b', 'c', 'd' ] ] ), + 'b' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'c' ] ] ), + 'c' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + 'd' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), ], 'out' => ' mw.loader.addSource( { @@ -47,7 +49,137 @@ mw.loader.addSource( { } ); mw.loader.register( [ [ - "test.blank", + "a", + "{blankVer}", + [ + 1, + 3 + ] + ], + [ + "b", + "{blankVer}", + [ + 2 + ] + ], + [ + "c", + "{blankVer}" + ], + [ + "d", + "{blankVer}" + ] +] );', + ] ], + [ [ + 'msg' => 'Optimise the dependency tree (tolerate unknown deps)', + 'modules' => [ + 'a' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'b', 'c', 'x' ] ] ), + 'b' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'c', 'x' ] ] ), + 'c' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + ], + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} ); +mw.loader.register( [ + [ + "a", + "{blankVer}", + [ + 1, + "x" + ] + ], + [ + "b", + "{blankVer}", + [ + 2, + "x" + ] + ], + [ + "c", + "{blankVer}" + ] +] );', + ] ], + [ [ + // Regression test for T223402. + 'msg' => 'Optimise the dependency tree (indirect circular dependency)', + 'modules' => [ + 'top' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'middle1', 'util' ] ] ), + 'middle1' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'middle2', 'util' ] ] ), + 'middle2' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'bottom' ] ] ), + 'bottom' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'top' ] ] ), + 'util' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + ], + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} ); +mw.loader.register( [ + [ + "top", + "{blankVer}", + [ + 1, + 4 + ] + ], + [ + "middle1", + "{blankVer}", + [ + 2, + 4 + ] + ], + [ + "middle2", + "{blankVer}", + [ + 3 + ] + ], + [ + "bottom", + "{blankVer}", + [ + 0 + ] + ], + [ + "util", + "{blankVer}" + ] +] );', + ] ], + [ [ + // Regression test for T223402. + 'msg' => 'Optimise the dependency tree (direct circular dependency)', + 'modules' => [ + 'top' => new ResourceLoaderTestModule( [ 'dependencies' => [ 'util', 'top' ] ] ), + 'util' => new ResourceLoaderTestModule( [ 'dependencies' => [] ] ), + ], + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} ); +mw.loader.register( [ + [ + "top", + "{blankVer}", + [ + 1, + 0 + ] + ], + [ + "util", "{blankVer}" ] ] );', @@ -454,8 +586,7 @@ mw.loader.register( [ /** * @dataProvider provideGetModuleRegistrations - * @covers ResourceLoaderStartUpModule::getModuleRegistrations - * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies + * @covers ResourceLoaderStartUpModule * @covers ResourceLoader::makeLoaderRegisterScript */ public function testGetModuleRegistrations( $case ) { diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php index 85a47de4b5..544afae95c 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -34,6 +34,42 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { $this->assertSame( 1, $ranHook, 'Hook was called' ); } + public static function provideInvalidModuleName() { + return [ + 'name with 300 chars' => [ str_repeat( 'x', 300 ) ], + 'name with bang' => [ 'this!that' ], + 'name with comma' => [ 'this,that' ], + 'name with pipe' => [ 'this|that' ], + ]; + } + + public static function provideValidModuleName() { + return [ + 'empty string' => [ '' ], + 'simple name' => [ 'this.and-that2' ], + 'name with 100 chars' => [ str_repeat( 'x', 100 ) ], + 'name with hash' => [ 'this#that' ], + 'name with slash' => [ 'this/that' ], + 'name with at' => [ 'this@that' ], + ]; + } + + /** + * @dataProvider provideInvalidModuleName + * @covers ResourceLoader + */ + public function testIsValidModuleName_invalid( $name ) { + $this->assertFalse( ResourceLoader::isValidModuleName( $name ) ); + } + + /** + * @dataProvider provideValidModuleName + * @covers ResourceLoader + */ + public function testIsValidModuleName_valid( $name ) { + $this->assertTrue( ResourceLoader::isValidModuleName( $name ) ); + } + /** * @covers ResourceLoader::register * @covers ResourceLoader::getModule @@ -60,6 +96,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { /** * @covers ResourceLoader::register + * @group medium */ public function testRegisterEmptyString() { $module = new ResourceLoaderTestModule(); @@ -70,6 +107,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { /** * @covers ResourceLoader::register + * @group medium */ public function testRegisterInvalidName() { $resourceLoader = new EmptyResourceLoader(); @@ -111,7 +149,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() ); $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() ); $this->assertEquals( - [ 'test.foo', 'test.bar' ], + [ 'startup', 'test.foo', 'test.bar' ], $resourceLoader->getModuleNames() ); } @@ -133,9 +171,8 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { 'simple scripts' => [ true, [ 'scripts' => 'example.js' ] ], - 'simple scripts, raw and targets' => [ true, [ + 'simple scripts with targets' => [ true, [ 'scripts' => [ 'a.js', 'b.js' ], - 'raw' => true, 'targets' => [ 'desktop', 'mobile' ], ] ], 'FileModule' => [ true, @@ -318,7 +355,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { * @covers ResourceLoader::getSources */ public function testAddSource( $name, $info, $expected ) { - $rl = new ResourceLoader; + $rl = new EmptyResourceLoader; $rl->addSource( $name, $info ); if ( is_array( $expected ) ) { foreach ( $expected as $source ) { @@ -333,7 +370,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { * @covers ResourceLoader::addSource */ public function testAddSourceDupe() { - $rl = new ResourceLoader; + $rl = new EmptyResourceLoader; $this->setExpectedException( MWException::class, 'ResourceLoader duplicate source addition error' ); @@ -345,7 +382,7 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { * @covers ResourceLoader::addSource */ public function testAddSourceInvalid() { - $rl = new ResourceLoader; + $rl = new EmptyResourceLoader; $this->setExpectedException( MWException::class, 'with no "loadScript" key' ); $rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] ); } @@ -623,7 +660,7 @@ END * @covers ResourceLoader::getLoadScript */ public function testGetLoadScript() { - $rl = new ResourceLoader(); + $rl = new EmptyResourceLoader(); $sources = self::fakeSources(); $rl->addSource( $sources ); foreach ( [ 'examplewiki', 'example2wiki' ] as $name ) { @@ -881,12 +918,13 @@ END * @covers ResourceLoader::makeModuleResponse */ public function testMakeModuleResponseStartupError() { - $rl = new EmptyResourceLoader(); + // This is an integration test that uses a lot of MediaWiki state, + // provide the full Config object here. + $rl = new EmptyResourceLoader( MediaWikiServices::getInstance()->getMainConfig() ); $rl->register( [ 'foo' => self::getSimpleModuleMock( 'foo();' ), 'ferry' => self::getFailFerryMock(), 'bar' => self::getSimpleModuleMock( 'bar();' ), - 'startup' => [ 'class' => ResourceLoaderStartUpModule::class ], ] ); $context = $this->getResourceLoaderContext( [ @@ -897,7 +935,7 @@ END ); $this->assertEquals( - [ 'foo', 'ferry', 'bar', 'startup' ], + [ 'startup', 'foo', 'ferry', 'bar' ], $rl->getModuleNames(), 'getModuleNames' ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php index ad8fa78c7a..c1bdebec66 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php @@ -451,6 +451,7 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { class TestResourceLoaderWikiModule extends ResourceLoaderWikiModule { public static $returnFetchTitleInfo = null; + protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = null ) { $ret = self::$returnFetchTitleInfo; self::$returnFetchTitleInfo = null; diff --git a/tests/phpunit/includes/search/SearchEngineTest.php b/tests/phpunit/includes/search/SearchEngineTest.php index 0c6520eed5..2772b0d897 100644 --- a/tests/phpunit/includes/search/SearchEngineTest.php +++ b/tests/phpunit/includes/search/SearchEngineTest.php @@ -1,5 +1,7 @@ search = new $searchType( $this->db ); + $lb = LoadBalancerSingle::newFromConnection( $this->db ); + $this->search = new $searchType( $lb ); } protected function tearDown() { @@ -187,7 +190,7 @@ class SearchEngineTest extends MediaWikiLangTestCase { $match = $res->getIterator()->current(); $snippet = "A " . $phrase . ""; $this->assertStringStartsWith( $snippet, - $match->getTextSnippet( $res->termMatches() ), + $match->getTextSnippet(), "Highlight a phrase search" ); } diff --git a/tests/phpunit/includes/search/SearchIndexFieldTest.php b/tests/phpunit/includes/search/SearchIndexFieldTest.php deleted file mode 100644 index 8b4119e0d3..0000000000 --- a/tests/phpunit/includes/search/SearchIndexFieldTest.php +++ /dev/null @@ -1,56 +0,0 @@ -getMockBuilder( SearchIndexFieldDefinition::class ) - ->setMethods( [ 'getMapping' ] ) - ->setConstructorArgs( [ $n1, $t1 ] ) - ->getMock(); - $field2 = - $this->getMockBuilder( SearchIndexFieldDefinition::class ) - ->setMethods( [ 'getMapping' ] ) - ->setConstructorArgs( [ $n2, $t2 ] ) - ->getMock(); - - if ( $result ) { - $this->assertNotFalse( $field1->merge( $field2 ) ); - } else { - $this->assertFalse( $field1->merge( $field2 ) ); - } - - $field1->setFlag( 0xFF ); - $this->assertFalse( $field1->merge( $field2 ) ); - - $field1->setMergeCallback( - function ( $a, $b ) { - return "test"; - } - ); - $this->assertEquals( "test", $field1->merge( $field2 ) ); - } - -} diff --git a/tests/phpunit/includes/session/MetadataMergeExceptionTest.php b/tests/phpunit/includes/session/MetadataMergeExceptionTest.php deleted file mode 100644 index 8cb4302a4e..0000000000 --- a/tests/phpunit/includes/session/MetadataMergeExceptionTest.php +++ /dev/null @@ -1,30 +0,0 @@ - 'bar' ]; - - $ex = new MetadataMergeException(); - $this->assertInstanceOf( \UnexpectedValueException::class, $ex ); - $this->assertSame( [], $ex->getContext() ); - - $ex2 = new MetadataMergeException( 'Message', 42, $ex, $data ); - $this->assertSame( 'Message', $ex2->getMessage() ); - $this->assertSame( 42, $ex2->getCode() ); - $this->assertSame( $ex, $ex2->getPrevious() ); - $this->assertSame( $data, $ex2->getContext() ); - - $ex->setContext( $data ); - $this->assertSame( $data, $ex->getContext() ); - } - -} diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php index 48c3d179e7..a1fdf8a3a6 100644 --- a/tests/phpunit/includes/session/SessionBackendTest.php +++ b/tests/phpunit/includes/session/SessionBackendTest.php @@ -2,6 +2,7 @@ namespace MediaWiki\Session; +use Wikimedia\AtEase\AtEase; use Config; use MediaWikiTestCase; use User; @@ -900,7 +901,7 @@ class SessionBackendTest extends MediaWikiTestCase { $manager->globalSessionRequest = $request; session_id( self::SESSIONID ); - \Wikimedia\quietCall( 'session_start' ); + AtEase::quietCall( 'session_start' ); $_SESSION['foo'] = __METHOD__; $backend->resetId(); $this->assertNotEquals( self::SESSIONID, $backend->getId() ); @@ -938,7 +939,7 @@ class SessionBackendTest extends MediaWikiTestCase { $manager->globalSessionRequest = $request; session_id( self::SESSIONID . 'x' ); - \Wikimedia\quietCall( 'session_start' ); + AtEase::quietCall( 'session_start' ); $backend->unpersist(); $this->assertSame( self::SESSIONID . 'x', session_id() ); session_write_close(); diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php deleted file mode 100644 index 2b06d971a6..0000000000 --- a/tests/phpunit/includes/session/SessionIdTest.php +++ /dev/null @@ -1,22 +0,0 @@ -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/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php index b33cd24a34..cd0867d2ef 100644 --- a/tests/phpunit/includes/session/SessionManagerTest.php +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -711,10 +711,10 @@ class SessionManagerTest extends MediaWikiTestCase { ] ); $expect = [ - 'Foo' => [], - 'Bar' => [ 'X', 'Bar1', 3 => 'Bar2' ], - 'Quux' => [ 'Quux' ], - 'Baz' => [], + 'Foo' => null, + 'Bar' => null, + 'Quux' => null, + 'Baz' => null, ]; $this->assertEquals( $expect, $manager->getVaryHeaders() ); diff --git a/tests/phpunit/includes/shell/CommandTest.php b/tests/phpunit/includes/shell/CommandTest.php index 2e03163885..c5e8e897ab 100644 --- a/tests/phpunit/includes/shell/CommandTest.php +++ b/tests/phpunit/includes/shell/CommandTest.php @@ -119,15 +119,18 @@ class CommandTest extends PHPUnit\Framework\TestCase { } public function testT69870() { - $commandLine = wfIsWindows() - // 333 = 331 + CRLF - ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) ) - : 'printf "%-333333s" "*"'; + if ( wfIsWindows() ) { + // T209159: Anonymous pipe under Windows does not support asynchronous read and write, + // and the default buffer is too small (~4K), it is easy to be blocked. + $this->markTestSkipped( + 'T209159: Anonymous pipe under Windows cannot withstand such a large amount of data' + ); + } // Test several times because it involves a race condition that may randomly succeed or fail for ( $i = 0; $i < 10; $i++ ) { $command = new Command(); - $output = $command->unsafeParams( $commandLine ) + $output = $command->unsafeParams( 'printf "%-333333s" "*"' ) ->execute() ->getStdout(); $this->assertEquals( 333333, strlen( $output ) ); diff --git a/tests/phpunit/includes/shell/ShellTest.php b/tests/phpunit/includes/shell/ShellTest.php index 3c05583806..5fb3ac0f5f 100644 --- a/tests/phpunit/includes/shell/ShellTest.php +++ b/tests/phpunit/includes/shell/ShellTest.php @@ -10,8 +10,6 @@ use Wikimedia\TestingAccessWrapper; */ class ShellTest extends MediaWikiTestCase { - use MediaWikiCoversValidator; - public function testIsDisabled() { $this->assertInternalType( 'bool', Shell::isDisabled() ); // sanity } diff --git a/tests/phpunit/includes/site/CachingSiteStoreTest.php b/tests/phpunit/includes/site/CachingSiteStoreTest.php deleted file mode 100644 index f04d35ca02..0000000000 --- a/tests/phpunit/includes/site/CachingSiteStoreTest.php +++ /dev/null @@ -1,167 +0,0 @@ - - */ -class CachingSiteStoreTest extends MediaWikiTestCase { - - /** - * @covers CachingSiteStore::getSites - */ - public function testGetSites() { - $testSites = TestSites::getSites(); - - $store = new CachingSiteStore( - $this->getHashSiteStore( $testSites ), - ObjectCache::getLocalClusterInstance() - ); - - $sites = $store->getSites(); - - $this->assertInstanceOf( SiteList::class, $sites ); - - /** - * @var Site $site - */ - foreach ( $sites as $site ) { - $this->assertInstanceOf( Site::class, $site ); - } - - foreach ( $testSites as $site ) { - if ( $site->getGlobalId() !== null ) { - $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); - } - } - } - - /** - * @covers CachingSiteStore::saveSites - */ - public function testSaveSites() { - $store = new CachingSiteStore( - new HashSiteStore(), ObjectCache::getLocalClusterInstance() - ); - - $sites = []; - - $site = new Site(); - $site->setGlobalId( 'ertrywuutr' ); - $site->setLanguageCode( 'en' ); - $sites[] = $site; - - $site = new MediaWikiSite(); - $site->setGlobalId( 'sdfhxujgkfpth' ); - $site->setLanguageCode( 'nl' ); - $sites[] = $site; - - $this->assertTrue( $store->saveSites( $sites ) ); - - $site = $store->getSite( 'ertrywuutr' ); - $this->assertInstanceOf( Site::class, $site ); - $this->assertEquals( 'en', $site->getLanguageCode() ); - - $site = $store->getSite( 'sdfhxujgkfpth' ); - $this->assertInstanceOf( Site::class, $site ); - $this->assertEquals( 'nl', $site->getLanguageCode() ); - } - - /** - * @covers CachingSiteStore::reset - */ - public function testReset() { - $dbSiteStore = $this->getMockBuilder( SiteStore::class ) - ->disableOriginalConstructor() - ->getMock(); - - $dbSiteStore->expects( $this->any() ) - ->method( 'getSite' ) - ->will( $this->returnValue( $this->getTestSite() ) ); - - $dbSiteStore->expects( $this->any() ) - ->method( 'getSites' ) - ->will( $this->returnCallback( function () { - $siteList = new SiteList(); - $siteList->setSite( $this->getTestSite() ); - - return $siteList; - } ) ); - - $store = new CachingSiteStore( $dbSiteStore, ObjectCache::getLocalClusterInstance() ); - - // initialize internal cache - $this->assertGreaterThan( 0, $store->getSites()->count(), 'count sites' ); - - $store->getSite( 'enwiki' )->setLanguageCode( 'en-ca' ); - - // sanity check: $store should have the new language code for 'enwiki' - $this->assertEquals( 'en-ca', $store->getSite( 'enwiki' )->getLanguageCode(), 'sanity check' ); - - // purge cache - $store->reset(); - - // the internal cache of $store should be updated, and now pulling - // the site from the 'fallback' DBSiteStore with the original language code. - $this->assertEquals( 'en', $store->getSite( 'enwiki' )->getLanguageCode(), 'reset' ); - } - - public function getTestSite() { - $enwiki = new MediaWikiSite(); - $enwiki->setGlobalId( 'enwiki' ); - $enwiki->setLanguageCode( 'en' ); - - return $enwiki; - } - - /** - * @covers CachingSiteStore::clear - */ - public function testClear() { - $store = new CachingSiteStore( - new HashSiteStore(), ObjectCache::getLocalClusterInstance() - ); - $this->assertTrue( $store->clear() ); - - $site = $store->getSite( 'enwiki' ); - $this->assertNull( $site ); - - $sites = $store->getSites(); - $this->assertEquals( 0, $sites->count() ); - } - - /** - * @param Site[] $sites - * - * @return SiteStore - */ - private function getHashSiteStore( array $sites ) { - $siteStore = new HashSiteStore(); - $siteStore->saveSites( $sites ); - - return $siteStore; - } - -} diff --git a/tests/phpunit/includes/site/HashSiteStoreTest.php b/tests/phpunit/includes/site/HashSiteStoreTest.php deleted file mode 100644 index 6269fd39dc..0000000000 --- a/tests/phpunit/includes/site/HashSiteStoreTest.php +++ /dev/null @@ -1,105 +0,0 @@ - - */ -class HashSiteStoreTest extends MediaWikiTestCase { - - /** - * @covers HashSiteStore::getSites - */ - public function testGetSites() { - $expectedSites = []; - - foreach ( TestSites::getSites() as $testSite ) { - $siteId = $testSite->getGlobalId(); - $expectedSites[$siteId] = $testSite; - } - - $siteStore = new HashSiteStore( $expectedSites ); - - $this->assertEquals( new SiteList( $expectedSites ), $siteStore->getSites() ); - } - - /** - * @covers HashSiteStore::saveSite - * @covers HashSiteStore::getSite - */ - public function testSaveSite() { - $store = new HashSiteStore(); - - $site = new Site(); - $site->setGlobalId( 'dewiki' ); - - $this->assertCount( 0, $store->getSites(), '0 sites in store' ); - - $store->saveSite( $site ); - - $this->assertCount( 1, $store->getSites(), 'Store has 1 sites' ); - $this->assertEquals( $site, $store->getSite( 'dewiki' ), 'Store has dewiki' ); - } - - /** - * @covers HashSiteStore::saveSites - */ - public function testSaveSites() { - $store = new HashSiteStore(); - - $sites = []; - - $site = new Site(); - $site->setGlobalId( 'enwiki' ); - $site->setLanguageCode( 'en' ); - $sites[] = $site; - - $site = new MediaWikiSite(); - $site->setGlobalId( 'eswiki' ); - $site->setLanguageCode( 'es' ); - $sites[] = $site; - - $this->assertCount( 0, $store->getSites(), '0 sites in store' ); - - $store->saveSites( $sites ); - - $this->assertCount( 2, $store->getSites(), 'Store has 2 sites' ); - $this->assertTrue( $store->getSites()->hasSite( 'enwiki' ), 'Store has enwiki' ); - $this->assertTrue( $store->getSites()->hasSite( 'eswiki' ), 'Store has eswiki' ); - } - - /** - * @covers HashSiteStore::clear - */ - public function testClear() { - $store = new HashSiteStore(); - - $site = new Site(); - $site->setGlobalId( 'arwiki' ); - $store->saveSite( $site ); - - $this->assertCount( 1, $store->getSites(), '1 site in store' ); - - $store->clear(); - $this->assertCount( 0, $store->getSites(), '0 sites in store' ); - } -} diff --git a/tests/phpunit/includes/skins/SkinFactoryTest.php b/tests/phpunit/includes/skins/SkinFactoryTest.php deleted file mode 100644 index 4289fd9188..0000000000 --- a/tests/phpunit/includes/skins/SkinFactoryTest.php +++ /dev/null @@ -1,82 +0,0 @@ -register( 'fallback', 'Fallback', function () { - return new SkinFallback(); - } ); - $this->assertTrue( true ); // No exception thrown - $this->setExpectedException( InvalidArgumentException::class ); - $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithNoBuilders() { - $factory = new SkinFactory(); - $this->setExpectedException( SkinException::class ); - $factory->makeSkin( 'nobuilderregistered' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithInvalidCallback() { - $factory = new SkinFactory(); - $factory->register( 'unittest', 'Unittest', function () { - return true; // Not a Skin object - } ); - $this->setExpectedException( UnexpectedValueException::class ); - $factory->makeSkin( 'unittest' ); - } - - /** - * @covers SkinFactory::makeSkin - */ - public function testMakeSkinWithValidCallback() { - $factory = new SkinFactory(); - $factory->register( 'testfallback', 'TestFallback', function () { - return new SkinFallback(); - } ); - - $skin = $factory->makeSkin( 'testfallback' ); - $this->assertInstanceOf( Skin::class, $skin ); - $this->assertInstanceOf( SkinFallback::class, $skin ); - $this->assertEquals( 'fallback', $skin->getSkinName() ); - } - - /** - * @covers Skin::__construct - * @covers Skin::getSkinName - */ - public function testGetSkinName() { - $skin = new SkinFallback(); - $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' ); - $skin = new SkinFallback( 'testname' ); - $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' ); - } - - /** - * @covers SkinFactory::getSkinNames - */ - public function testGetSkinNames() { - $factory = new SkinFactory(); - // A fake callback we can use that will never be called - $callback = function () { - // NOP - }; - $factory->register( 'skin1', 'Skin1', $callback ); - $factory->register( 'skin2', 'Skin2', $callback ); - $names = $factory->getSkinNames(); - $this->assertArrayHasKey( 'skin1', $names ); - $this->assertArrayHasKey( 'skin2', $names ); - $this->assertEquals( 'Skin1', $names['skin1'] ); - $this->assertEquals( 'Skin2', $names['skin2'] ); - } -} diff --git a/tests/phpunit/includes/sparql/SparqlClientTest.php b/tests/phpunit/includes/sparql/SparqlClientTest.php index 8eb233b688..62af48920b 100644 --- a/tests/phpunit/includes/sparql/SparqlClientTest.php +++ b/tests/phpunit/includes/sparql/SparqlClientTest.php @@ -1,4 +1,5 @@ getTestSysop()->getUser(); $this->assertConditions( diff --git a/tests/phpunit/includes/specialpage/FormSpecialPageTestCase.php b/tests/phpunit/includes/specialpage/FormSpecialPageTestCase.php index a3b5adb858..a09fd7c693 100644 --- a/tests/phpunit/includes/specialpage/FormSpecialPageTestCase.php +++ b/tests/phpunit/includes/specialpage/FormSpecialPageTestCase.php @@ -1,4 +1,7 @@ getTestUser()->getUser(); $user->mBlockedby = $user->getName(); - $user->mBlock = new Block( [ + $user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $user->getId(), 'reason' => 'sitewide block', @@ -52,7 +55,7 @@ abstract class FormSpecialPageTestCase extends SpecialPageTestBase { $user = clone $this->getTestUser()->getUser(); $user->mBlockedby = $user->getName(); - $user->mBlock = new Block( [ + $user->mBlock = new DatabaseBlock( [ 'address' => '127.0.8.1', 'by' => $user->getId(), 'reason' => 'partial block', diff --git a/tests/phpunit/includes/specialpage/SpecialPageTest.php b/tests/phpunit/includes/specialpage/SpecialPageTest.php index ec4bf0fc43..783adce4da 100644 --- a/tests/phpunit/includes/specialpage/SpecialPageTest.php +++ b/tests/phpunit/includes/specialpage/SpecialPageTest.php @@ -1,7 +1,5 @@ assertTrue( true ); } - - public function provideBuildPrevNextNavigation() { - yield [ 0, 20, false, false ]; - yield [ 17, 20, false, false ]; - yield [ 0, 17, false, false ]; - yield [ 0, 20, true, 'Foo' ]; - yield [ 17, 20, true, 'Föö_Bär' ]; - } - - /** - * @dataProvider provideBuildPrevNextNavigation - */ - public function testBuildPrevNextNavigation( $offset, $limit, $atEnd, $subPage ) { - $this->setUserLang( Language::factory( 'qqx' ) ); // disable i18n - - $specialPage = new SpecialPage( 'Watchlist' ); - $specialPage = TestingAccessWrapper::newFromObject( $specialPage ); - - $html = $specialPage->buildPrevNextNavigation( - $offset, - $limit, - [ 'x' => 25 ], - $atEnd, - $subPage - ); - - $this->assertStringStartsWith( '(viewprevnext:', $html ); - - preg_match_all( '!!', $html, $m, PREG_PATTERN_ORDER ); - $links = $m[0]; - - foreach ( $links as $a ) { - if ( $subPage ) { - $this->assertContains( 'Special:Watchlist/' . wfUrlencode( $subPage ), $a ); - } else { - $this->assertContains( 'Special:Watchlist', $a ); - $this->assertNotContains( 'Special:Watchlist/', $a ); - } - $this->assertContains( 'x=25', $a ); - } - - $i = 0; - - if ( $offset > 0 ) { - $this->assertContains( - 'limit=' . $limit . '&offset=' . max( 0, $offset - $limit ) . '&', - $links[ $i ] - ); - $this->assertContains( 'title="(prevn-title: ' . $limit . ')"', $links[$i] ); - $this->assertContains( 'class="mw-prevlink"', $links[$i] ); - $this->assertContains( '>(prevn: ' . $limit . ')<', $links[$i] ); - $i += 1; - } - - if ( !$atEnd ) { - $this->assertContains( - 'limit=' . $limit . '&offset=' . ( $offset + $limit ) . '&', - $links[ $i ] - ); - $this->assertContains( 'title="(nextn-title: ' . $limit . ')"', $links[$i] ); - $this->assertContains( 'class="mw-nextlink"', $links[$i] ); - $this->assertContains( '>(nextn: ' . $limit . ')<', $links[$i] ); - $i += 1; - } - - $this->assertCount( 5 + $i, $links ); - - $this->assertContains( 'limit=20&offset=' . $offset, $links[$i] ); - $this->assertContains( 'title="(shown-title: 20)"', $links[$i] ); - $this->assertContains( 'class="mw-numlink"', $links[$i] ); - $this->assertContains( '>20<', $links[$i] ); - $i += 4; - - $this->assertContains( 'limit=500&offset=' . $offset, $links[$i] ); - $this->assertContains( 'title="(shown-title: 500)"', $links[$i] ); - $this->assertContains( 'class="mw-numlink"', $links[$i] ); - $this->assertContains( '>500<', $links[$i] ); - } - } diff --git a/tests/phpunit/includes/specials/SpecialBlockTest.php b/tests/phpunit/includes/specials/SpecialBlockTest.php index c1f2e42064..86e3295eb8 100644 --- a/tests/phpunit/includes/specials/SpecialBlockTest.php +++ b/tests/phpunit/includes/specials/SpecialBlockTest.php @@ -1,6 +1,7 @@ insertBlock(); // Refresh the block from the database. - $block = Block::newFromTarget( $block->getTarget() ); + $block = DatabaseBlock::newFromTarget( $block->getTarget() ); $page = $this->newSpecialPage(); @@ -109,7 +110,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $pageSaturn = $this->getExistingTestPage( 'Saturn' ); $pageMars = $this->getExistingTestPage( 'Mars' ); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), @@ -129,7 +130,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $block->insert(); // Refresh the block from the database. - $block = Block::newFromTarget( $block->getTarget() ); + $block = DatabaseBlock::newFromTarget( $block->getTarget() ); $page = $this->newSpecialPage(); @@ -179,7 +180,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); } @@ -196,7 +197,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $context = RequestContext::getMain(); // Create a block that will be updated. - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), @@ -228,7 +229,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertSame( '1', $block->isAutoblocking() ); @@ -277,7 +278,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertCount( 2, $block->getRestrictions() ); @@ -331,7 +332,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertFalse( $block->isSitewide() ); @@ -347,7 +348,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertFalse( $block->isSitewide() ); @@ -362,7 +363,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertFalse( $block->isSitewide() ); @@ -374,7 +375,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $this->assertTrue( $result ); - $block = Block::newFromTarget( $badActor ); + $block = DatabaseBlock::newFromTarget( $badActor ); $this->assertSame( $reason, $block->getReason() ); $this->assertSame( $expiry, $block->getExpiry() ); $this->assertTrue( $block->isSitewide() ); @@ -420,7 +421,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $$var = $users[$$var]; } - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $blockedUser->getName(), 'user' => $blockedUser->getId(), 'by' => $blockPerformer->getId(), @@ -454,7 +455,7 @@ class SpecialBlockTest extends SpecialPageTestBase { $badActor = $this->getTestUser()->getUser(); $sysop = $this->getTestSysop()->getUser(); - $block = new \Block( [ + $block = new DatabaseBlock( [ 'address' => $badActor->getName(), 'user' => $badActor->getId(), 'by' => $sysop->getId(), diff --git a/tests/phpunit/includes/specials/SpecialMuteTest.php b/tests/phpunit/includes/specials/SpecialMuteTest.php new file mode 100644 index 0000000000..e31357cb33 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMuteTest.php @@ -0,0 +1,110 @@ +setMwGlobals( [ + 'wgEnableUserEmailBlacklist' => true + ] ); + } + + /** + * @inheritDoc + */ + protected function newSpecialPage() { + return new SpecialMute(); + } + + /** + * @covers SpecialMute::execute + * @expectedExceptionMessage username requested could not be found + * @expectedException ErrorPageError + */ + public function testInvalidTarget() { + $user = $this->getTestUser()->getUser(); + $this->executeSpecialPage( + 'InvalidUser', null, 'qqx', $user + ); + } + + /** + * @covers SpecialMute::execute + * @expectedExceptionMessage Muting users from sending you emails is not enabled + * @expectedException ErrorPageError + */ + public function testEmailBlacklistNotEnabled() { + $this->setMwGlobals( [ + 'wgEnableUserEmailBlacklist' => false + ] ); + + $user = $this->getTestUser()->getUser(); + $this->executeSpecialPage( + $user->getName(), null, 'qqx', $user + ); + } + + /** + * @covers SpecialMute::execute + * @expectedException UserNotLoggedIn + */ + public function testUserNotLoggedIn() { + $this->executeSpecialPage( 'TestUser' ); + } + + /** + * @covers SpecialMute::execute + */ + public function testMuteAddsUserToEmailBlacklist() { + $this->setMwGlobals( [ + 'wgCentralIdLookupProvider' => 'local', + ] ); + + $targetUser = $this->getTestUser()->getUser(); + + $loggedInUser = $this->getMutableTestUser()->getUser(); + $loggedInUser->setOption( 'email-blacklist', "999" ); + $loggedInUser->confirmEmail(); + $loggedInUser->saveSettings(); + + $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => 1 ], true ); + list( $html, ) = $this->executeSpecialPage( + $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser + ); + + $this->assertContains( 'specialmute-success', $html ); + $this->assertEquals( + "999\n" . $targetUser->getId(), + $loggedInUser->getOption( 'email-blacklist' ) + ); + } + + /** + * @covers SpecialMute::execute + */ + public function testUnmuteRemovesUserFromEmailBlacklist() { + $this->setMwGlobals( [ + 'wgCentralIdLookupProvider' => 'local', + ] ); + + $targetUser = $this->getTestUser()->getUser(); + + $loggedInUser = $this->getMutableTestUser()->getUser(); + $loggedInUser->setOption( 'email-blacklist', "999\n" . $targetUser->getId() ); + $loggedInUser->confirmEmail(); + $loggedInUser->saveSettings(); + + $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => false ], true ); + list( $html, ) = $this->executeSpecialPage( + $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser + ); + + $this->assertContains( 'specialmute-success', $html ); + $this->assertEquals( "999", $loggedInUser->getOption( 'email-blacklist' ) ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php index 1a4fe4f825..4dd6c8045e 100644 --- a/tests/phpunit/includes/specials/SpecialSearchTest.php +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -1,4 +1,5 @@ true, 'ns6' => true). Null to use default options. + * [ 'ns5' => true, 'ns6' => true ]. Null to use default options. * @param array $userOptions User options to test with. For example: - * array('searchNs5' => 1 );. Null to use default options. + * [ 'searchNs5' => 1 ];. Null to use default options. * @param string $expectedProfile An expected search profile name * @param array $expectedNS Expected namespaces * @param string $message diff --git a/tests/phpunit/includes/specials/pagers/BlockListPagerTest.php b/tests/phpunit/includes/specials/pagers/BlockListPagerTest.php index bd37c04cd5..c0eadac56e 100644 --- a/tests/phpunit/includes/specials/pagers/BlockListPagerTest.php +++ b/tests/phpunit/includes/specials/pagers/BlockListPagerTest.php @@ -1,5 +1,6 @@ $target, 'by' => $this->getTestSysop()->getUser()->getId(), 'reason' => 'Parce que', diff --git a/tests/phpunit/includes/title/ForeignTitleTest.php b/tests/phpunit/includes/title/ForeignTitleTest.php deleted file mode 100644 index f2fccc7577..0000000000 --- a/tests/phpunit/includes/title/ForeignTitleTest.php +++ /dev/null @@ -1,103 +0,0 @@ -assertEquals( true, $title->isNamespaceIdKnown() ); - $this->assertEquals( $expectedId, $title->getNamespaceId() ); - $this->assertEquals( $expectedName, $title->getNamespaceName() ); - $this->assertEquals( $expectedText, $title->getText() ); - } - - public function testUnknownNamespaceCheck() { - $title = new ForeignTitle( null, 'this', 'that' ); - - $this->assertEquals( false, $title->isNamespaceIdKnown() ); - $this->assertEquals( 'this', $title->getNamespaceName() ); - $this->assertEquals( 'that', $title->getText() ); - } - - public function testUnknownNamespaceError() { - $this->setExpectedException( MWException::class ); - $title = new ForeignTitle( null, 'this', 'that' ); - $title->getNamespaceId(); - } - - public function fullTextProvider() { - return [ - [ - new ForeignTitle( 20, 'Contributor', 'JohnDoe' ), - 'Contributor:JohnDoe' - ], - [ - new ForeignTitle( '1', 'Discussion', 'Capital' ), - 'Discussion:Capital' - ], - [ - new ForeignTitle( 0, '', 'MainNamespace' ), - 'MainNamespace' - ], - [ - new ForeignTitle( 4, 'Some ns', 'Article title with spaces' ), - 'Some_ns:Article_title_with_spaces' - ], - ]; - } - - /** - * @dataProvider fullTextProvider - */ - public function testFullText( ForeignTitle $title, $fullText ) { - $this->assertEquals( $fullText, $title->getFullText() ); - } -} diff --git a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php index b8bf087a72..fdd200b6d6 100644 --- a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php +++ b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php @@ -336,6 +336,14 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase { 'X' . str_repeat( 'x', 251 ) ], // Test decoding and normalization [ '"ñ"', NS_MAIN, 'en', new TitleValue( NS_MAIN, '"ñ"' ) ], + [ 'X#ñ', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'ñ' ) ], + // target section parsing + 'empty fragment' => [ 'X#', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X' ) ], + 'double hash' => [ 'X##', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', '#' ) ], + 'fragment with hash' => [ 'X#z#z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z#z' ) ], + 'fragment with space' => [ 'X#z z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z z' ) ], + 'fragment with percent' => [ 'X#z%z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z%z' ) ], + 'fragment with amp' => [ 'X#z&z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z&z' ) ], ]; } diff --git a/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php b/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php deleted file mode 100644 index 9aa3578da7..0000000000 --- a/tests/phpunit/includes/title/NamespaceAwareForeignTitleFactoryTest.php +++ /dev/null @@ -1,101 +0,0 @@ - '', 1 => 'Talk', 100 => 'Portal', 9000 => 'Bogus' - ]; - - $factory = new NamespaceAwareForeignTitleFactory( $foreignNamespaces ); - $testTitle = $factory->createForeignTitle( $title, $ns ); - - $this->assertEquals( $testTitle->isNamespaceIdKnown(), - $foreignTitle->isNamespaceIdKnown() ); - - if ( - $testTitle->isNamespaceIdKnown() && - $foreignTitle->isNamespaceIdKnown() - ) { - $this->assertEquals( $testTitle->getNamespaceId(), - $foreignTitle->getNamespaceId() ); - } - - $this->assertEquals( $testTitle->getNamespaceName(), - $foreignTitle->getNamespaceName() ); - $this->assertEquals( $testTitle->getText(), $foreignTitle->getText() ); - } -} diff --git a/tests/phpunit/includes/title/NamespaceInfoTest.php b/tests/phpunit/includes/title/NamespaceInfoTest.php index 556c640bd6..c1e258dac0 100644 --- a/tests/phpunit/includes/title/NamespaceInfoTest.php +++ b/tests/phpunit/includes/title/NamespaceInfoTest.php @@ -6,6 +6,7 @@ */ use MediaWiki\Config\ServiceOptions; +use MediaWiki\Linker\LinkTarget; class NamespaceInfoTest extends MediaWikiTestCase { /********************************************************************************************** @@ -402,7 +403,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { } /** - * @param $contentNamespaces To pass to constructor + * @param mixed $contentNamespaces To pass to constructor * @param array $expected * @dataProvider provideGetContentNamespaces * @covers NamespaceInfo::getContentNamespaces @@ -601,6 +602,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { * getSubject/Talk/Associated * %{ */ + /** * @dataProvider provideSubjectTalk * @covers NamespaceInfo::getSubject @@ -687,73 +689,88 @@ class NamespaceInfoTest extends MediaWikiTestCase { /** * @dataProvider provideSpecialNamespaces - * @covers NamespaceInfo::getTalk - * @covers NamespaceInfo::getTalkPage + * @covers NamespaceInfo::getAssociated * @covers NamespaceInfo::isMethodValidFor * * @param int $ns */ - public function testGetTalkPage_special( $ns ) { - $this->setExpectedException( MWException::class, - "NamespaceInfo::getTalk does not make any sense for given namespace $ns" ); - $this->newObj()->getTalkPage( new TitleValue( $ns, 'A' ) ); + public function testGetAssociated_special( $ns ) { + $this->setExpectedException( + MWException::class, + "NamespaceInfo::getAssociated does not make any sense for given namespace $ns" + ); + $this->newObj()->getAssociated( $ns ); + } + + public static function provideCanHaveTalkPage() { + return [ + [ new TitleValue( NS_MAIN, 'Test' ), true ], + [ new TitleValue( NS_TALK, 'Test' ), true ], + [ new TitleValue( NS_USER, 'Test' ), true ], + [ new TitleValue( NS_SPECIAL, 'Test' ), false ], + [ new TitleValue( NS_MEDIA, 'Test' ), false ], + [ new TitleValue( NS_MAIN, '', 'Kittens' ), false ], + [ new TitleValue( NS_MAIN, 'Kittens', '', 'acme' ), false ], + ]; } /** - * @dataProvider provideSpecialNamespaces - * @covers NamespaceInfo::getTalk - * @covers NamespaceInfo::getTalkPage - * @covers NamespaceInfo::isMethodValidFor - * @covers Title::getTalkPage - * - * @param int $ns + * @dataProvider provideCanHaveTalkPage + * @covers NamespaceInfo::canHaveTalkPage */ - public function testTitleGetTalkPage_special( $ns ) { - $this->setExpectedException( MWException::class, - "NamespaceInfo::getTalk does not make any sense for given namespace $ns" ); - Title::makeTitle( $ns, 'A' )->getTalkPage(); + public function testCanHaveTalkPage( LinkTarget $t, $expected ) { + $actual = $this->newObj()->canHaveTalkPage( $t ); + $this->assertEquals( $expected, $actual, $t->getDBkey() ); + } + + public static function provideGetTalkPage_good() { + return [ + [ new TitleValue( NS_MAIN, 'Test' ), new TitleValue( NS_TALK, 'Test' ) ], + [ new TitleValue( NS_TALK, 'Test' ), new TitleValue( NS_TALK, 'Test' ) ], + [ new TitleValue( NS_USER, 'Test' ), new TitleValue( NS_USER_TALK, 'Test' ) ], + ]; } /** - * @dataProvider provideSpecialNamespaces - * @covers NamespaceInfo::getAssociated + * @dataProvider provideGetTalkPage_good + * @covers NamespaceInfo::getTalk + * @covers NamespaceInfo::getTalkPage * @covers NamespaceInfo::isMethodValidFor - * - * @param int $ns */ - public function testGetAssociated_special( $ns ) { - $this->setExpectedException( MWException::class, - "NamespaceInfo::getAssociated does not make any sense for given namespace $ns" ); - $this->newObj()->getAssociated( $ns ); + public function testGetTalkPage_good( LinkTarget $t, LinkTarget $expected ) { + $actual = $this->newObj()->getTalkPage( $t ); + $this->assertEquals( $expected, $actual, $t->getDBkey() ); + } + + public static function provideGetTalkPage_bad() { + return [ + [ new TitleValue( NS_SPECIAL, 'Test' ) ], + [ new TitleValue( NS_MEDIA, 'Test' ) ], + [ new TitleValue( NS_MAIN, '', 'Kittens' ) ], + [ new TitleValue( NS_MAIN, 'Kittens', '', 'acme' ) ], + ]; } /** - * @dataProvider provideSpecialNamespaces - * @covers NamespaceInfo::getAssociated - * @covers NamespaceInfo::getAssociatedPage + * @dataProvider provideGetTalkPage_bad + * @covers NamespaceInfo::getTalk + * @covers NamespaceInfo::getTalkPage * @covers NamespaceInfo::isMethodValidFor - * - * @param int $ns */ - public function testGetAssociatedPage_special( $ns ) { - $this->setExpectedException( MWException::class, - "NamespaceInfo::getAssociated does not make any sense for given namespace $ns" ); - $this->newObj()->getAssociatedPage( new TitleValue( $ns, 'A' ) ); + public function testGetTalkPage_bad( LinkTarget $t ) { + $this->setExpectedException( MWException::class ); + $this->newObj()->getTalkPage( $t ); } /** - * @dataProvider provideSpecialNamespaces + * @dataProvider provideGetTalkPage_bad * @covers NamespaceInfo::getAssociated * @covers NamespaceInfo::getAssociatedPage * @covers NamespaceInfo::isMethodValidFor - * @covers Title::getOtherPage - * - * @param int $ns */ - public function testTitleGetOtherPage_special( $ns ) { - $this->setExpectedException( MWException::class, - "NamespaceInfo::getAssociated does not make any sense for given namespace $ns" ); - Title::makeTitle( $ns, 'A' )->getOtherPage(); + public function testGetAssociatedPage_bad( LinkTarget $t ) { + $this->setExpectedException( MWException::class ); + $this->newObj()->getAssociatedPage( $t ); } /** @@ -880,6 +897,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { // No canonical namespace names // %{ + /** * @covers NamespaceInfo::getCanonicalNamespaces */ @@ -982,6 +1000,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { // Hook namespaces // %{ + /** * @return array Expected canonical namespaces */ @@ -1047,6 +1066,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { // Extra namespaces // %{ + /** * @return NamespaceInfo */ @@ -1102,6 +1122,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { // Canonical namespace caching // %{ + /** * @covers NamespaceInfo::getCanonicalNamespaces */ @@ -1295,11 +1316,7 @@ class NamespaceInfoTest extends MediaWikiTestCase { 'No namespace restriction' => [ [ '', 'autoconfirmed', 'sysop' ], NS_TALK ], 'Restricted to autoconfirmed' => [ [ '', 'sysop' ], NS_MAIN ], 'Restricted to sysop' => [ [ '' ], NS_USER ], - // @todo Bug -- 'sysop' protection should be allowed in this case. Someone who's - // autoconfirmed and also privileged can edit this namespace, and would be blocked by - // the sysop protection. - 'Restricted to someone in two groups' => [ [ '' ], 101 ], - + 'Restricted to someone in two groups' => [ [ '', 'sysop' ], 101 ], 'No special permissions' => [ [ '' ], NS_TALK, $this->getMockUser() ], 'autoconfirmed' => [ [ '', 'autoconfirmed' ], diff --git a/tests/phpunit/includes/title/TitleValueTest.php b/tests/phpunit/includes/title/TitleValueTest.php deleted file mode 100644 index bbeb068007..0000000000 --- a/tests/phpunit/includes/title/TitleValueTest.php +++ /dev/null @@ -1,149 +0,0 @@ -assertEquals( $ns, $title->getNamespace() ); - $this->assertTrue( $title->inNamespace( $ns ) ); - $this->assertEquals( $text, $title->getText() ); - $this->assertEquals( $fragment, $title->getFragment() ); - $this->assertEquals( $hasFragment, $title->hasFragment() ); - $this->assertEquals( $interwiki, $title->getInterwiki() ); - $this->assertEquals( $hasInterwiki, $title->isExternal() ); - } - - public function badConstructorProvider() { - return [ - [ 'foo', 'title', 'fragment', '' ], - [ null, 'title', 'fragment', '' ], - [ 2.3, 'title', 'fragment', '' ], - - [ NS_MAIN, 5, 'fragment', '' ], - [ NS_MAIN, null, 'fragment', '' ], - [ NS_USER, '', 'fragment', '' ], - [ NS_MAIN, 'foo bar', '', '' ], - [ NS_MAIN, 'bar_', '', '' ], - [ NS_MAIN, '_foo', '', '' ], - [ NS_MAIN, ' eek ', '', '' ], - - [ NS_MAIN, 'title', 5, '' ], - [ NS_MAIN, 'title', null, '' ], - [ NS_MAIN, 'title', [], '' ], - - [ NS_MAIN, 'title', '', 5 ], - [ NS_MAIN, 'title', null, 5 ], - [ NS_MAIN, 'title', [], 5 ], - ]; - } - - /** - * @dataProvider badConstructorProvider - */ - public function testConstructionErrors( $ns, $text, $fragment, $interwiki ) { - $this->setExpectedException( InvalidArgumentException::class ); - new TitleValue( $ns, $text, $fragment, $interwiki ); - } - - public function fragmentTitleProvider() { - return [ - [ new TitleValue( NS_MAIN, 'Test' ), 'foo' ], - [ new TitleValue( NS_TALK, 'Test', 'foo' ), '' ], - [ new TitleValue( NS_CATEGORY, 'Test', 'foo' ), 'bar' ], - ]; - } - - /** - * @dataProvider fragmentTitleProvider - */ - public function testCreateFragmentTitle( TitleValue $title, $fragment ) { - $fragmentTitle = $title->createFragmentTarget( $fragment ); - - $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() ); - $this->assertEquals( $title->getText(), $fragmentTitle->getText() ); - $this->assertEquals( $fragment, $fragmentTitle->getFragment() ); - } - - public function getTextProvider() { - return [ - [ 'Foo', 'Foo' ], - [ 'Foo_Bar', 'Foo Bar' ], - ]; - } - - /** - * @dataProvider getTextProvider - */ - public function testGetText( $dbkey, $text ) { - $title = new TitleValue( NS_MAIN, $dbkey ); - - $this->assertEquals( $text, $title->getText() ); - } - - public function provideTestToString() { - yield [ - new TitleValue( 0, 'Foo' ), - '0:Foo' - ]; - yield [ - new TitleValue( 1, 'Bar_Baz' ), - '1:Bar_Baz' - ]; - yield [ - new TitleValue( 9, 'JoJo', 'Frag' ), - '9:JoJo#Frag' - ]; - yield [ - new TitleValue( 200, 'tea', 'Fragment', 'wikicode' ), - 'wikicode:200:tea#Fragment' - ]; - } - - /** - * @dataProvider provideTestToString - */ - public function testToString( TitleValue $value, $expected ) { - $this->assertSame( - $expected, - $value->__toString() - ); - } -} diff --git a/tests/phpunit/includes/upload/UploadBaseTest.php b/tests/phpunit/includes/upload/UploadBaseTest.php index 58c69e3229..cafc846e9a 100644 --- a/tests/phpunit/includes/upload/UploadBaseTest.php +++ b/tests/phpunit/includes/upload/UploadBaseTest.php @@ -585,6 +585,42 @@ class UploadBaseTest extends MediaWikiTestCase { [ '', false ], ]; } + + /** + * @covers UploadBase::detectScript + * @dataProvider provideDetectScript + */ + public function testDetectScript( $filename, $mime, $extension, $expected, $message ) { + $result = $this->upload->detectScript( $filename, $mime, $extension ); + $this->assertSame( $expected, $result, $message ); + } + + public static function provideDetectScript() { + global $IP; + return [ + [ + "$IP/tests/phpunit/data/upload/png-plain.png", + 'image/png', + 'png', + false, + 'PNG with no suspicious things in it, should pass.' + ], + [ + "$IP/tests/phpunit/data/upload/png-embedded-breaks-ie5.png", + 'image/png', + 'png', + true, + 'PNG with embedded data that IE5/6 interprets as HTML; should be rejected.' + ], + [ + "$IP/tests/phpunit/data/upload/jpeg-a-href-in-metadata.jpg", + 'image/jpeg', + 'jpeg', + false, + 'JPEG with innocuous HTML in metadata from a flickr photo; should pass (T27707).' + ], + ]; + } } class UploadTestHandler extends UploadBase { diff --git a/tests/phpunit/includes/user/ExternalUserNamesTest.php b/tests/phpunit/includes/user/ExternalUserNamesTest.php index 429bda4625..53f47a0934 100644 --- a/tests/phpunit/includes/user/ExternalUserNamesTest.php +++ b/tests/phpunit/includes/user/ExternalUserNamesTest.php @@ -27,6 +27,8 @@ class ExternalUserNamesTest extends MediaWikiTestCase { * @dataProvider provideGetUserLinkTitle */ public function testGetUserLinkTitle( $username, $expected ) { + $this->setContentLang( 'en' ); + $interwikiLookupMock = $this->getMockBuilder( InterwikiLookup::class ) ->getMock(); diff --git a/tests/phpunit/includes/user/LocalIdLookupTest.php b/tests/phpunit/includes/user/LocalIdLookupTest.php index 58441f095f..e38d77b72b 100644 --- a/tests/phpunit/includes/user/LocalIdLookupTest.php +++ b/tests/phpunit/includes/user/LocalIdLookupTest.php @@ -1,5 +1,7 @@ getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $this->localUsers[2]->getName(), 'by' => $sysop->getId(), 'reason' => __METHOD__, @@ -29,7 +31,7 @@ class LocalIdLookupTest extends MediaWikiTestCase { ] ); $block->insert(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'address' => $this->localUsers[3]->getName(), 'by' => $sysop->getId(), 'reason' => __METHOD__, diff --git a/tests/phpunit/includes/user/PasswordResetTest.php b/tests/phpunit/includes/user/PasswordResetTest.php index ca57c10b6f..b0c0fec6b2 100644 --- a/tests/phpunit/includes/user/PasswordResetTest.php +++ b/tests/phpunit/includes/user/PasswordResetTest.php @@ -1,6 +1,8 @@ true, 'allowsAuthenticationDataChange' => true, 'canEditPrivate' => true, - 'block' => new Block( [ 'createAccount' => true ] ), + 'block' => new DatabaseBlock( [ 'createAccount' => true ] ), 'globalBlock' => null, 'isAllowed' => false, ], @@ -94,7 +96,7 @@ class PasswordResetTest extends MediaWikiTestCase { 'enableEmail' => true, 'allowsAuthenticationDataChange' => true, 'canEditPrivate' => true, - 'block' => new Block( [] ), + 'block' => new DatabaseBlock( [] ), 'globalBlock' => null, 'isAllowed' => true, ], @@ -140,6 +142,34 @@ class PasswordResetTest extends MediaWikiTestCase { 'globalBlock' => null, 'isAllowed' => false, ], + 'blocked with multiple blocks, all allowing password reset' => [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'block' => new CompositeBlock( [ + 'originalBlocks' => [ + new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ), + new Block( [] ), + ] + ] ), + 'globalBlock' => null, + 'isAllowed' => true, + ], + 'blocked with multiple blocks, not all allowing password reset' => [ + 'passwordResetRoutes' => [ 'username' => true ], + 'enableEmail' => true, + 'allowsAuthenticationDataChange' => true, + 'canEditPrivate' => true, + 'block' => new CompositeBlock( [ + 'originalBlocks' => [ + new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ), + new SystemBlock( [ 'systemBlock' => 'proxy' ] ), + ] + ] ), + 'globalBlock' => null, + 'isAllowed' => false, + ], 'all OK' => [ 'passwordResetRoutes' => [ 'username' => true ], 'enableEmail' => true, diff --git a/tests/phpunit/includes/user/UserArrayFromResultTest.php b/tests/phpunit/includes/user/UserArrayFromResultTest.php deleted file mode 100644 index beaacec800..0000000000 --- a/tests/phpunit/includes/user/UserArrayFromResultTest.php +++ /dev/null @@ -1,114 +0,0 @@ -getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class ) - ->disableOriginalConstructor(); - - $resultWrapper = $resultWrapper->getMock(); - $resultWrapper->expects( $this->atLeastOnce() ) - ->method( 'current' ) - ->will( $this->returnValue( $row ) ); - $resultWrapper->expects( $this->any() ) - ->method( 'numRows' ) - ->will( $this->returnValue( $numRows ) ); - - return $resultWrapper; - } - - private function getRowWithUsername( $username = 'fooUser' ) { - $row = new stdClass(); - $row->user_name = $username; - return $row; - } - - private function getUserArrayFromResult( $resultWrapper ) { - return new UserArrayFromResult( $resultWrapper ); - } - - /** - * @covers UserArrayFromResult::__construct - */ - public function testConstructionWithFalseRow() { - $row = false; - $resultWrapper = $this->getMockResultWrapper( $row ); - - $object = $this->getUserArrayFromResult( $resultWrapper ); - - $this->assertEquals( $resultWrapper, $object->res ); - $this->assertSame( 0, $object->key ); - $this->assertEquals( $row, $object->current ); - } - - /** - * @covers UserArrayFromResult::__construct - */ - public function testConstructionWithRow() { - $username = 'addshore'; - $row = $this->getRowWithUsername( $username ); - $resultWrapper = $this->getMockResultWrapper( $row ); - - $object = $this->getUserArrayFromResult( $resultWrapper ); - - $this->assertEquals( $resultWrapper, $object->res ); - $this->assertSame( 0, $object->key ); - $this->assertInstanceOf( User::class, $object->current ); - $this->assertEquals( $username, $object->current->mName ); - } - - public static function provideNumberOfRows() { - return [ - [ 0 ], - [ 1 ], - [ 122 ], - ]; - } - - /** - * @dataProvider provideNumberOfRows - * @covers UserArrayFromResult::count - */ - public function testCountWithVaryingValues( $numRows ) { - $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( - $this->getRowWithUsername(), - $numRows - ) ); - $this->assertEquals( $numRows, $object->count() ); - } - - /** - * @covers UserArrayFromResult::current - */ - public function testCurrentAfterConstruction() { - $username = 'addshore'; - $userRow = $this->getRowWithUsername( $username ); - $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $userRow ) ); - $this->assertInstanceOf( User::class, $object->current() ); - $this->assertEquals( $username, $object->current()->mName ); - } - - public function provideTestValid() { - return [ - [ $this->getRowWithUsername(), true ], - [ false, false ], - ]; - } - - /** - * @dataProvider provideTestValid - * @covers UserArrayFromResult::valid - */ - public function testValid( $input, $expected ) { - $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $input ) ); - $this->assertEquals( $expected, $object->valid() ); - } - - // @todo unit test for key() - // @todo unit test for next() - // @todo unit test for rewind() -} diff --git a/tests/phpunit/includes/user/UserGroupMembershipTest.php b/tests/phpunit/includes/user/UserGroupMembershipTest.php index 4862747b4f..340a4c3106 100644 --- a/tests/phpunit/includes/user/UserGroupMembershipTest.php +++ b/tests/phpunit/includes/user/UserGroupMembershipTest.php @@ -46,6 +46,8 @@ class UserGroupMembershipTest extends MediaWikiTestCase { $this->userTester->addGroup( 'unittesters' ); $this->expiryTime = wfTimestamp( TS_MW, time() + 100500 ); $this->userTester->addGroup( 'testwriters', $this->expiryTime ); + + $this->resetServices(); } /** diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index aeeae11833..5a978f93ba 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -3,6 +3,8 @@ define( 'NS_UNITTEST', 5600 ); define( 'NS_UNITTEST_TALK', 5601 ); +use MediaWiki\Block\DatabaseBlock; +use MediaWiki\Block\CompositeBlock; use MediaWiki\Block\Restriction\PageRestriction; use MediaWiki\Block\Restriction\NamespaceRestriction; use MediaWiki\Block\SystemBlock; @@ -65,6 +67,15 @@ class UserTest extends MediaWikiTestCase { ]; } + private function setSessionUser( User $user, WebRequest $request ) { + $this->setMwGlobals( 'wgUser', $user ); + RequestContext::getMain()->setUser( $user ); + RequestContext::getMain()->setRequest( $request ); + TestingAccessWrapper::newFromObject( $user )->mRequest = $request; + $request->getSession()->setUser( $user ); + $this->overrideMwServices(); + } + /** * @covers User::getGroupPermissions */ @@ -118,12 +129,12 @@ class UserTest extends MediaWikiTestCase { $this->assertNotContains( 'nukeworld', $rights, 'sanity check' ); // Add a hook manipluating the rights - $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) { + $this->setTemporaryHook( 'UserGetRights', function ( $user, &$rights ) { $rights[] = 'nukeworld'; $rights = array_diff( $rights, [ 'writetest' ] ); - } ] ] ); + } ); - $userWrapper->mRights = null; + $this->resetServices(); $rights = $user->getRights(); $this->assertContains( 'test', $rights ); $this->assertContains( 'runtest', $rights ); @@ -145,7 +156,7 @@ class UserTest extends MediaWikiTestCase { $mockRequest->method( 'getSession' )->willReturn( $session ); $userWrapper->mRequest = $mockRequest; - $userWrapper->mRights = null; + $this->resetServices(); $rights = $user->getRights(); $this->assertContains( 'test', $rights ); $this->assertNotContains( 'runtest', $rights ); @@ -366,7 +377,6 @@ class UserTest extends MediaWikiTestCase { * - ensure the password is not the same as the username * - ensure the username/password combo isn't forbidden * @covers User::checkPasswordValidity() - * @covers User::getPasswordValidity() * @covers User::isValidPassword() */ public function testCheckPasswordValidity() { @@ -394,7 +404,6 @@ class UserTest extends MediaWikiTestCase { ], ], ] ); - $this->hideDeprecated( 'User::getPasswordValidity' ); $user = static::getTestUser()->getUser(); @@ -405,24 +414,20 @@ class UserTest extends MediaWikiTestCase { $this->assertFalse( $user->isValidPassword( 'a' ) ); $this->assertFalse( $user->checkPasswordValidity( 'a' )->isGood() ); $this->assertTrue( $user->checkPasswordValidity( 'a' )->isOK() ); - $this->assertEquals( 'passwordtooshort', $user->getPasswordValidity( 'a' ) ); // Maximum length $longPass = str_repeat( 'a', 41 ); $this->assertFalse( $user->isValidPassword( $longPass ) ); $this->assertFalse( $user->checkPasswordValidity( $longPass )->isGood() ); $this->assertFalse( $user->checkPasswordValidity( $longPass )->isOK() ); - $this->assertEquals( 'passwordtoolong', $user->getPasswordValidity( $longPass ) ); // Matches username $this->assertFalse( $user->checkPasswordValidity( $user->getName() )->isGood() ); $this->assertTrue( $user->checkPasswordValidity( $user->getName() )->isOK() ); - $this->assertEquals( 'password-name-match', $user->getPasswordValidity( $user->getName() ) ); // On the forbidden list $user = User::newFromName( 'Useruser' ); $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() ); - $this->assertEquals( 'password-login-forbidden', $user->getPasswordValidity( 'Passpass' ) ); } /** @@ -608,7 +613,7 @@ class UserTest extends MediaWikiTestCase { $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1tmp ); $expiryFiveHours = wfTimestamp() + ( 5 * 60 * 60 ); - $block = new Block( [ + $block = new DatabaseBlock( [ 'enableAutoblock' => true, 'expiry' => wfTimestamp( TS_MW, $expiryFiveHours ), ] ); @@ -622,8 +627,8 @@ class UserTest extends MediaWikiTestCase { // Confirm that the block has been applied as required. $this->assertTrue( $user1->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user1->getBlock() ); - $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertInstanceOf( DatabaseBlock::class, $user1->getBlock() ); + $this->assertEquals( DatabaseBlock::TYPE_USER, $block->getType() ); $this->assertTrue( $block->isAutoblocking() ); $this->assertGreaterThanOrEqual( 1, $block->getId() ); @@ -631,8 +636,10 @@ class UserTest extends MediaWikiTestCase { $cookies = $request1->response()->getCookies(); $this->assertArrayHasKey( 'wmsitetitleBlockID', $cookies ); $this->assertEquals( $expiryFiveHours, $cookies['wmsitetitleBlockID']['expire'] ); - $cookieValue = Block::getIdFromCookieValue( $cookies['wmsitetitleBlockID']['value'] ); - $this->assertEquals( $block->getId(), $cookieValue ); + $cookieId = MediaWikiServices::getInstance()->getBlockManager()->getIdFromCookieValue( + $cookies['wmsitetitleBlockID']['value'] + ); + $this->assertEquals( $block->getId(), $cookieId ); // 2. Create a new request, set the cookies, and see if the (anon) user is blocked. $request2 = new FauxRequest(); @@ -643,7 +650,7 @@ class UserTest extends MediaWikiTestCase { $this->assertNotEquals( $user1->getToken(), $user2->getToken() ); $this->assertTrue( $user2->isAnon() ); $this->assertFalse( $user2->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user2->getBlock() ); + $this->assertInstanceOf( DatabaseBlock::class, $user2->getBlock() ); // Non-strict type-check. $this->assertEquals( true, $user2->getBlock()->isAutoblocking(), 'Autoblock does not work' ); // Can't directly compare the objects because of member type differences. @@ -659,7 +666,7 @@ class UserTest extends MediaWikiTestCase { $user3 = User::newFromSession( $request3 ); $user3->load(); $this->assertTrue( $user3->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user3->getBlock() ); + $this->assertInstanceOf( DatabaseBlock::class, $user3->getBlock() ); $this->assertEquals( true, $user3->getBlock()->isAutoblocking() ); // Non-strict type-check. // Clean up. @@ -688,7 +695,7 @@ class UserTest extends MediaWikiTestCase { $testUser = $this->getTestUser()->getUser(); $request1 = new FauxRequest(); $request1->getSession()->setUser( $testUser ); - $block = new Block( [ 'enableAutoblock' => true ] ); + $block = new DatabaseBlock( [ 'enableAutoblock' => true ] ); $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $testUser ); $res = $block->insert(); @@ -699,8 +706,8 @@ class UserTest extends MediaWikiTestCase { // 2. Test that the cookie IS NOT present. $this->assertTrue( $user->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user->getBlock() ); - $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertInstanceOf( DatabaseBlock::class, $user->getBlock() ); + $this->assertEquals( DatabaseBlock::TYPE_USER, $block->getType() ); $this->assertTrue( $block->isAutoblocking() ); $this->assertGreaterThanOrEqual( 1, $user->getBlockId() ); $this->assertGreaterThanOrEqual( $block->getId(), $user->getBlockId() ); @@ -733,7 +740,7 @@ class UserTest extends MediaWikiTestCase { $user1Tmp = $this->getTestUser()->getUser(); $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1Tmp ); - $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] ); + $block = new DatabaseBlock( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] ); $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1Tmp ); $res = $block->insert(); @@ -744,8 +751,8 @@ class UserTest extends MediaWikiTestCase { // 2. Test the cookie's expiry timestamp. $this->assertTrue( $user1->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user1->getBlock() ); - $this->assertEquals( Block::TYPE_USER, $block->getType() ); + $this->assertInstanceOf( DatabaseBlock::class, $user1->getBlock() ); + $this->assertEquals( DatabaseBlock::TYPE_USER, $block->getType() ); $this->assertTrue( $block->isAutoblocking() ); $this->assertGreaterThanOrEqual( 1, $user1->getBlockId() ); $cookies = $request1->response()->getCookies(); @@ -782,28 +789,20 @@ class UserTest extends MediaWikiTestCase { * @covers User::getBlockedStatus */ public function testSoftBlockRanges() { - $setSessionUser = function ( User $user, WebRequest $request ) { - $this->setMwGlobals( 'wgUser', $user ); - RequestContext::getMain()->setUser( $user ); - RequestContext::getMain()->setRequest( $request ); - TestingAccessWrapper::newFromObject( $user )->mRequest = $request; - $request->getSession()->setUser( $user ); - $this->overrideMwServices(); - }; $this->setMwGlobals( 'wgSoftBlockRanges', [ '10.0.0.0/8' ] ); // IP isn't in $wgSoftBlockRanges $wgUser = new User(); $request = new FauxRequest(); $request->setIP( '192.168.0.1' ); - $setSessionUser( $wgUser, $request ); + $this->setSessionUser( $wgUser, $request ); $this->assertNull( $wgUser->getBlock() ); // IP is in $wgSoftBlockRanges $wgUser = new User(); $request = new FauxRequest(); $request->setIP( '10.20.30.40' ); - $setSessionUser( $wgUser, $request ); + $this->setSessionUser( $wgUser, $request ); $block = $wgUser->getBlock(); $this->assertInstanceOf( SystemBlock::class, $block ); $this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() ); @@ -812,7 +811,7 @@ class UserTest extends MediaWikiTestCase { $wgUser = $this->getTestUser()->getUser(); $request = new FauxRequest(); $request->setIP( '10.20.30.40' ); - $setSessionUser( $wgUser, $request ); + $this->setSessionUser( $wgUser, $request ); $this->assertFalse( $wgUser->isAnon(), 'sanity check' ); $this->assertNull( $wgUser->getBlock() ); } @@ -838,7 +837,7 @@ class UserTest extends MediaWikiTestCase { $user1tmp = $this->getTestUser()->getUser(); $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1tmp ); - $block = new Block( [ 'enableAutoblock' => true ] ); + $block = new DatabaseBlock( [ 'enableAutoblock' => true ] ); $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1tmp ); $res = $block->insert(); @@ -883,7 +882,7 @@ class UserTest extends MediaWikiTestCase { $user1tmp = $this->getTestUser()->getUser(); $request1 = new FauxRequest(); $request1->getSession()->setUser( $user1tmp ); - $block = new Block( [ 'enableAutoblock' => true ] ); + $block = new DatabaseBlock( [ 'enableAutoblock' => true ] ); $block->setBlocker( $this->getTestSysop()->getUser() ); $block->setTarget( $user1tmp ); $res = $block->insert(); @@ -891,7 +890,7 @@ class UserTest extends MediaWikiTestCase { $user1 = User::newFromSession( $request1 ); $user1->mBlock = $block; $user1->load(); - $this->assertInstanceOf( Block::class, $user1->getBlock() ); + $this->assertInstanceOf( DatabaseBlock::class, $user1->getBlock() ); // 2. Create a new request, set the cookie to just the block ID, and the user should // still get blocked when they log in again. @@ -903,7 +902,7 @@ class UserTest extends MediaWikiTestCase { $this->assertNotEquals( $user1->getToken(), $user2->getToken() ); $this->assertTrue( $user2->isAnon() ); $this->assertFalse( $user2->isLoggedIn() ); - $this->assertInstanceOf( Block::class, $user2->getBlock() ); + $this->assertInstanceOf( DatabaseBlock::class, $user2->getBlock() ); $this->assertEquals( true, $user2->getBlock()->isAutoblocking() ); // Non-strict type-check. // Clean up. @@ -929,9 +928,11 @@ class UserTest extends MediaWikiTestCase { $this->setMwGlobals( 'wgRateLimitsExcludedIPs', [] ); $noRateLimitUser = $this->getMockBuilder( User::class )->disableOriginalConstructor() - ->setMethods( [ 'getIP', 'getRights' ] )->getMock(); + ->setMethods( [ 'getIP', 'getId', 'getGroups' ] )->getMock(); $noRateLimitUser->expects( $this->any() )->method( 'getIP' )->willReturn( '1.2.3.4' ); - $noRateLimitUser->expects( $this->any() )->method( 'getRights' )->willReturn( [ 'noratelimit' ] ); + $noRateLimitUser->expects( $this->any() )->method( 'getId' )->willReturn( 0 ); + $noRateLimitUser->expects( $this->any() )->method( 'getGroups' )->willReturn( [] ); + $this->overrideUserPermissions( $noRateLimitUser, 'noratelimit' ); $this->assertFalse( $noRateLimitUser->isPingLimitable() ); } @@ -1289,7 +1290,7 @@ class UserTest extends MediaWikiTestCase { // Block the user $blocker = $this->getTestSysop()->getUser(); - $block = new Block( [ + $block = new DatabaseBlock( [ 'hideName' => true, 'allowUsertalk' => false, 'reason' => 'Because', @@ -1301,7 +1302,7 @@ class UserTest extends MediaWikiTestCase { // Clear cache and confirm it loaded the block properly $user->clearInstanceCache(); - $this->assertInstanceOf( Block::class, $user->getBlock( false ) ); + $this->assertInstanceOf( DatabaseBlock::class, $user->getBlock( false ) ); $this->assertSame( $blocker->getName(), $user->blockedBy() ); $this->assertSame( 'Because', $user->blockedFor() ); $this->assertTrue( (bool)$user->isHidden() ); @@ -1319,6 +1320,35 @@ class UserTest extends MediaWikiTestCase { $this->assertFalse( $user->isBlockedFrom( $ut ) ); } + /** + * @covers User::getBlockedStatus + */ + public function testCompositeBlocks() { + $user = $this->getMutableTestUser()->getUser(); + $request = $user->getRequest(); + $this->setSessionUser( $user, $request ); + + $ipBlock = new Block( [ + 'address' => $user->getRequest()->getIP(), + 'by' => $this->getTestSysop()->getUser()->getId(), + 'createAccount' => true, + ] ); + $ipBlock->insert(); + + $userBlock = new Block( [ + 'address' => $user, + 'by' => $this->getTestSysop()->getUser()->getId(), + 'createAccount' => false, + ] ); + $userBlock->insert(); + + $block = $user->getBlock(); + $this->assertInstanceOf( CompositeBlock::class, $block ); + $this->assertTrue( $block->isCreateAccountBlocked() ); + $this->assertTrue( $block->appliesToPasswordReset() ); + $this->assertTrue( $block->appliesToNamespace( NS_MAIN ) ); + } + /** * @covers User::isBlockedFrom * @dataProvider provideIsBlockedFrom @@ -1326,7 +1356,7 @@ class UserTest extends MediaWikiTestCase { * @param bool $expect Expected result from User::isBlockedFrom() * @param array $options Additional test options: * - 'blockAllowsUTEdit': (bool, default true) Value for $wgBlockAllowsUTEdit - * - 'allowUsertalk': (bool, default false) Passed to Block::__construct() + * - 'allowUsertalk': (bool, default false) Passed to DatabaseBlock::__construct() * - 'pageRestrictions': (array|null) If non-empty, page restriction titles for the block. */ public function testIsBlockedFrom( $title, $expect, array $options = [] ) { @@ -1353,7 +1383,7 @@ class UserTest extends MediaWikiTestCase { $restrictions[] = new NamespaceRestriction( 0, $ns ); } - $block = new Block( [ + $block = new DatabaseBlock( [ 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), 'allowUsertalk' => $options['allowUsertalk'] ?? false, 'sitewide' => !$restrictions, @@ -1462,7 +1492,7 @@ class UserTest extends MediaWikiTestCase { ] ); // setup block - $block = new Block( [ + $block = new DatabaseBlock( [ 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 5 * 60 * 60 ) ), ] ); $block->setTarget( '1.2.3.4' ); @@ -1475,7 +1505,7 @@ class UserTest extends MediaWikiTestCase { // get user $user = User::newFromSession( $request ); - $user->trackBlockWithCookie(); + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $user ); // test cookie was set $cookies = $request->response()->getCookies(); @@ -1498,7 +1528,7 @@ class UserTest extends MediaWikiTestCase { ] ); // setup block - $block = new Block( [ + $block = new DatabaseBlock( [ 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 5 * 60 * 60 ) ), ] ); $block->setTarget( '1.2.3.4' ); @@ -1511,7 +1541,7 @@ class UserTest extends MediaWikiTestCase { // get user $user = User::newFromSession( $request ); - $user->trackBlockWithCookie(); + MediaWikiServices::getInstance()->getBlockManager()->trackBlockWithCookie( $user ); // test cookie was not set $cookies = $request->response()->getCookies(); @@ -1535,7 +1565,7 @@ class UserTest extends MediaWikiTestCase { ] ); // setup block - $block = new Block( [ + $block = new DatabaseBlock( [ 'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ), ] ); $block->setTarget( '1.2.3.4' ); diff --git a/tests/phpunit/includes/utils/BatchRowUpdateTest.php b/tests/phpunit/includes/utils/BatchRowUpdateTest.php index 52b143393f..dd21addaee 100644 --- a/tests/phpunit/includes/utils/BatchRowUpdateTest.php +++ b/tests/phpunit/includes/utils/BatchRowUpdateTest.php @@ -49,7 +49,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { $this->assertEquals( $response[$pos], $rows, "Testing row in position $pos" ); $pos++; } - // -1 is because the final array() marks the end and isnt included + // -1 is because the final [] marks the end and isn't included $this->assertEquals( count( $response ) - 1, $pos ); } diff --git a/tests/phpunit/includes/utils/ClassCollectorTest.php b/tests/phpunit/includes/utils/ClassCollectorTest.php index 9c7c50f0f6..6f8aa52720 100644 --- a/tests/phpunit/includes/utils/ClassCollectorTest.php +++ b/tests/phpunit/includes/utils/ClassCollectorTest.php @@ -29,10 +29,21 @@ class ClassCollectorTest extends PHPUnit\Framework\TestCase { "namespace Example;\nclass Foo {}\nclass_alias( 'Example\Foo', 'Bar' );", [ 'Example\Foo', 'Bar' ], ], + [ + // Support a multiline 'class' statement + "namespace Example;\nclass Foo extends\n\tFooBase {\n\t" + . "public function x() {}\n}\nclass_alias( 'Example\Foo', 'Bar' );", + [ 'Example\Foo', 'Bar' ], + ], [ "class_alias( Foo::class, 'Bar' );", [ 'Bar' ], ], + [ + // Support nested class_alias() calls + "if ( false ) {\n\tclass_alias( Foo::class, 'Bar' );\n}", + [ 'Bar' ], + ], [ // Namespaced class is not currently supported. Must use namespace declaration // earlier in the file. diff --git a/tests/phpunit/includes/utils/UIDGeneratorTest.php b/tests/phpunit/includes/utils/UIDGeneratorTest.php index 6b81a66fea..e600021e97 100644 --- a/tests/phpunit/includes/utils/UIDGeneratorTest.php +++ b/tests/phpunit/includes/utils/UIDGeneratorTest.php @@ -67,7 +67,7 @@ class UIDGeneratorTest extends PHPUnit\Framework\TestCase { } /** - * array( method, length, bits, hostbits ) + * [ method, length, bits, hostbits ] * NOTE: When adding a new method name here please update the covers tags for the tests! */ public static function provider_testTimestampedUID() { diff --git a/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php b/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php deleted file mode 100644 index f424b21b3e..0000000000 --- a/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php +++ /dev/null @@ -1,250 +0,0 @@ -getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'addWatch' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->addWatch( - new UserIdentityValue( 1, 'MockUser', 0 ), new TitleValue( 0, 'Foo' ) ); - } - - public function testAddWatchBatchForUser() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'addWatchBatchForUser' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->addWatchBatchForUser( new UserIdentityValue( 1, 'MockUser', 0 ), [] ); - } - - public function testRemoveWatch() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'removeWatch' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->removeWatch( - new UserIdentityValue( 1, 'MockUser', 0 ), new TitleValue( 0, 'Foo' ) ); - } - - public function testSetNotificationTimestampsForUser() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'setNotificationTimestampsForUser' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->setNotificationTimestampsForUser( - new UserIdentityValue( 1, 'MockUser', 0 ), - 'timestamp', - [] - ); - } - - public function testUpdateNotificationTimestamp() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'updateNotificationTimestamp' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->updateNotificationTimestamp( - new UserIdentityValue( 1, 'MockUser', 0 ), - new TitleValue( 0, 'Foo' ), - 'timestamp' - ); - } - - public function testResetNotificationTimestamp() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->never() )->method( 'resetNotificationTimestamp' ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->resetNotificationTimestamp( - new UserIdentityValue( 1, 'MockUser', 0 ), - new TitleValue( 0, 'Foo' ) - ); - } - - public function testCountWatchedItems() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() )->method( 'countWatchedItems' )->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countWatchedItems( - new UserIdentityValue( 1, 'MockUser', 0 ) - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testCountWatchers() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() )->method( 'countWatchers' )->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countWatchers( - new TitleValue( 0, 'Foo' ) - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testCountVisitingWatchers() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'countVisitingWatchers' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countVisitingWatchers( - new TitleValue( 0, 'Foo' ), - 9 - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testCountWatchersMultiple() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'countVisitingWatchersMultiple' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countWatchersMultiple( - [ new TitleValue( 0, 'Foo' ) ], - [] - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testCountVisitingWatchersMultiple() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'countVisitingWatchersMultiple' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countVisitingWatchersMultiple( - [ [ new TitleValue( 0, 'Foo' ), 99 ] ], - 11 - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testGetWatchedItem() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() )->method( 'getWatchedItem' )->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->getWatchedItem( - new UserIdentityValue( 1, 'MockUser', 0 ), - new TitleValue( 0, 'Foo' ) - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testLoadWatchedItem() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() )->method( 'loadWatchedItem' )->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->loadWatchedItem( - new UserIdentityValue( 1, 'MockUser', 0 ), - new TitleValue( 0, 'Foo' ) - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testGetWatchedItemsForUser() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'getWatchedItemsForUser' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->getWatchedItemsForUser( - new UserIdentityValue( 1, 'MockUser', 0 ), - [] - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testIsWatched() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() )->method( 'isWatched' )->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->isWatched( - new UserIdentityValue( 1, 'MockUser', 0 ), - new TitleValue( 0, 'Foo' ) - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testGetNotificationTimestampsBatch() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'getNotificationTimestampsBatch' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->getNotificationTimestampsBatch( - new UserIdentityValue( 1, 'MockUser', 0 ), - [ new TitleValue( 0, 'Foo' ) ] - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testCountUnreadNotifications() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $innerService->expects( $this->once() ) - ->method( 'countUnreadNotifications' ) - ->willReturn( __METHOD__ ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $return = $noWriteService->countUnreadNotifications( - new UserIdentityValue( 1, 'MockUser', 0 ), - 88 - ); - $this->assertEquals( __METHOD__, $return ); - } - - public function testDuplicateAllAssociatedEntries() { - /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ - $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); - $noWriteService = new NoWriteWatchedItemStore( $innerService ); - - $this->setExpectedException( DBReadOnlyError::class ); - $noWriteService->duplicateAllAssociatedEntries( - new TitleValue( 0, 'Foo' ), - new TitleValue( 0, 'Bar' ) - ); - } - -} diff --git a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php index 3ba8773c29..0b1d0132fb 100644 --- a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php +++ b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php @@ -10,8 +10,6 @@ use Wikimedia\TestingAccessWrapper; */ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { - use MediaWikiCoversValidator; - /** * @return PHPUnit_Framework_MockObject_MockObject|CommentStore */ diff --git a/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php index 82308de4ea..72db766a77 100644 --- a/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php +++ b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php @@ -1,4 +1,5 @@ - function ( $oldRev, $titleArg ) use ( $mockRevisionRecord, $title ) { + function ( $oldRev ) use ( $mockRevisionRecord ) { $this->assertSame( $mockRevisionRecord, $oldRev ); - $this->assertSame( $title, $titleArg ); return false; }, ], [ diff --git a/tests/phpunit/integration/includes/db/DatabaseSqliteTest.php b/tests/phpunit/integration/includes/db/DatabaseSqliteTest.php new file mode 100644 index 0000000000..6fa911b67e --- /dev/null +++ b/tests/phpunit/integration/includes/db/DatabaseSqliteTest.php @@ -0,0 +1,553 @@ +markTestSkipped( 'No SQLite support detected' ); + } + $this->db = $this->getMockBuilder( DatabaseSqlite::class ) + ->setConstructorArgs( [ [ + 'dbFilePath' => ':memory:', + 'schema' => false, + 'host' => false, + 'user' => false, + 'password' => false, + 'tablePrefix' => '', + 'cliMode' => true, + 'agent' => 'unit-tests', + 'flags' => DBO_DEFAULT, + 'variables' => [], + 'profiler' => null, + 'trxProfiler' => new TransactionProfiler(), + 'connLogger' => new NullLogger(), + 'queryLogger' => new NullLogger(), + 'errorLogger' => null, + 'deprecationLogger' => null, + ] ] )->setMethods( [ 'query' ] ) + ->getMock(); + $this->db->initConnection(); + $this->db->method( 'query' )->willReturn( true ); + if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { + $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); + } + } + + /** + * @param $sql + * @return string|string[]|null + */ + private function replaceVars( $sql ) { + $wrapper = TestingAccessWrapper::newFromObject( $this->db ); + // normalize spacing to hide implementation details + return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) ); + } + + private function assertResultIs( $expected, $res ) { + $this->assertNotNull( $res ); + $i = 0; + foreach ( $res as $row ) { + foreach ( $expected[$i] as $key => $value ) { + $this->assertTrue( isset( $row->$key ) ); + $this->assertEquals( $value, $row->$key ); + } + $i++; + } + $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); + } + + public static function provideAddQuotes() { + return [ + [ // #0: empty + '', "''" + ], + [ // #1: simple + 'foo bar', "'foo bar'" + ], + [ // #2: including quote + 'foo\'bar', "'foo''bar'" + ], + // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) + [ + "x\0y", + "x'780079'", + ], + [ // #4: blob object (must be represented as hex) + new Blob( "hello" ), + "x'68656c6c6f'", + ], + [ // #5: null + null, + "''", + ], + ]; + } + + /** + * @dataProvider provideAddQuotes() + * @covers DatabaseSqlite::addQuotes + */ + public function testAddQuotes( $value, $expected ) { + // check quoting + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); + + // ok, quoting works as expected, now try a round trip. + $re = $db->query( 'select ' . $db->addQuotes( $value ) ); + + $this->assertTrue( $re !== false, 'query failed' ); + + $row = $re->fetchRow(); + if ( $row ) { + if ( $value instanceof Blob ) { + $value = $value->fetch(); + } + + $this->assertEquals( $value, $row[0], 'string mangled by the database' ); + } else { + $this->fail( 'query returned no result' ); + } + } + + /** + * @covers DatabaseSqlite::replaceVars + */ + public function testReplaceVars() { + $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); + + $this->assertEquals( + "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", + $this->replaceVars( + "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " + . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " + . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" + ) + ); + + $this->assertEquals( + "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", + $this->replaceVars( + "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" + ) + ); + + $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", + $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) + ); + + $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", + $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), + 'Table name changed' + ); + + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) + ); + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) + ); + + $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", + $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) + ); + + $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", + $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) + ); + + $this->assertEquals( "DROP INDEX foo", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) + ); + + $this->assertEquals( "DROP INDEX foo -- dropping index", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) + ); + $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", + $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) + ); + } + + /** + * @covers DatabaseSqlite::tableName + */ + public function testTableName() { + // @todo Moar! + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $db->tablePrefix( 'foo_' ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructure() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->query( 'CREATE TABLE foo(foo, barfoo)' ); + $db->query( 'CREATE INDEX index1 ON foo(foo)' ); + $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), + 'Normal table duplication' + ); + $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' ); + $index = $indexList->next(); + $this->assertEquals( 'bar_index1', $index->name ); + $this->assertEquals( '0', $index->unique ); + $index = $indexList->next(); + $this->assertEquals( 'bar_index2', $index->name ); + $this->assertEquals( '1', $index->unique ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', + $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ), + 'Creation of temporary duplicate' + ); + $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' ); + $index = $indexList->next(); + $this->assertEquals( 'baz_index1', $index->name ); + $this->assertEquals( '0', $index->unique ); + $index = $indexList->next(); + $this->assertEquals( 'baz_index2', $index->name ); + $this->assertEquals( '1', $index->unique ); + $this->assertEquals( 0, + $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ), + 'Create a temporary duplicate only' + ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructureVirtual() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + if ( $db->getFulltextSearchModule() != 'FTS3' ) { + $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); + } + $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), + 'Duplication of virtual tables' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ), + "Can't create temporary virtual tables, should fall back to non-temporary duplication" + ); + } + + /** + * @covers DatabaseSqlite::deleteJoin + */ + public function testDeleteJoin() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); + $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); + $db->insert( 'a', [ + [ 'a_1' => 1 ], + [ 'a_1' => 2 ], + [ 'a_1' => 3 ], + ], + __METHOD__ + ); + $db->insert( 'b', [ + [ 'b_1' => 2, 'b_2' => 'a' ], + [ 'b_1' => 3, 'b_2' => 'b' ], + ], + __METHOD__ + ); + $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ ); + $res = $db->query( "SELECT * FROM a", __METHOD__ ); + $this->assertResultIs( [ + [ 'a_1' => 1 ], + [ 'a_1' => 3 ], + ], + $res + ); + } + + /** + * @coversNothing + */ + public function testEntireSchema() { + global $IP; + + $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); + if ( $result !== true ) { + $this->fail( $result ); + } + $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions + } + + /** + * Runs upgrades of older databases and compares results with current schema + * @todo Currently only checks list of tables + * @coversNothing + */ + public function testUpgrades() { + global $IP, $wgVersion, $wgProfiler; + + // Versions tested + $versions = [ + // '1.13', disabled for now, was totally screwed up + // SQLite wasn't included in 1.14 + '1.15', + '1.16', + '1.17', + '1.18', + '1.19', + '1.20', + '1.21', + '1.22', + '1.23', + ]; + + // Mismatches for these columns we can safely ignore + $ignoredColumns = [ + 'user_newtalk.user_last_timestamp', // r84185 + ]; + + $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); + + $profileToDb = false; + if ( isset( $wgProfiler['output'] ) ) { + $out = $wgProfiler['output']; + if ( $out === 'db' ) { + $profileToDb = true; + } elseif ( is_array( $out ) && in_array( 'db', $out ) ) { + $profileToDb = true; + } + } + + if ( $profileToDb ) { + $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); + } + $currentTables = $this->getTables( $currentDB ); + sort( $currentTables ); + + foreach ( $versions as $version ) { + $versions = "upgrading from $version to $wgVersion"; + $db = $this->prepareTestDB( $version ); + $tables = $this->getTables( $db ); + $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); + foreach ( $tables as $table ) { + $currentCols = $this->getColumns( $currentDB, $table ); + $cols = $this->getColumns( $db, $table ); + $this->assertEquals( + array_keys( $currentCols ), + array_keys( $cols ), + "Mismatching columns for table \"$table\" $versions" + ); + foreach ( $currentCols as $name => $column ) { + $fullName = "$table.$name"; + $this->assertEquals( + (bool)$column->pk, + (bool)$cols[$name]->pk, + "PRIMARY KEY status does not match for column $fullName $versions" + ); + if ( !in_array( $fullName, $ignoredColumns ) ) { + $this->assertEquals( + (bool)$column->notnull, + (bool)$cols[$name]->notnull, + "NOT NULL status does not match for column $fullName $versions" + ); + $this->assertEquals( + $column->dflt_value, + $cols[$name]->dflt_value, + "Default values does not match for column $fullName $versions" + ); + } + } + $currentIndexes = $this->getIndexes( $currentDB, $table ); + $indexes = $this->getIndexes( $db, $table ); + $this->assertEquals( + array_keys( $currentIndexes ), + array_keys( $indexes ), + "mismatching indexes for table \"$table\" $versions" + ); + } + $db->close(); + } + } + + /** + * @covers DatabaseSqlite::insertId + */ + public function testInsertIdType() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" ); + + $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); + $this->assertTrue( $insertion, "Insertion worked" ); + + $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); + $this->assertTrue( $db->close(), "closing database" ); + } + + /** + * @covers DatabaseSqlite::insert + */ + public function testInsertAffectedRows() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ ); + + $insertion = $db->insert( + 'testInsertAffectedRows', + [ + [ 'foo' => 10 ], + [ 'foo' => 12 ], + [ 'foo' => 1555 ], + ], + __METHOD__ + ); + $this->assertTrue( $insertion, "Insertion worked" ); + + $this->assertSame( 3, $db->affectedRows() ); + $this->assertTrue( $db->close(), "closing database" ); + } + + private function prepareTestDB( $version ) { + static $maint = null; + if ( $maint === null ) { + $maint = new FakeMaintenance(); + $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] ); + } + + global $IP; + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); + $updater = DatabaseUpdater::newForDB( $db, false, $maint ); + $updater->doUpdates( [ 'core' ] ); + + return $db; + } + + private function getTables( $db ) { + $list = array_flip( $db->listTables() ); + $excluded = [ + 'external_user', // removed from core in 1.22 + 'math', // moved out of core in 1.18 + 'trackbacks', // removed from core in 1.19 + 'searchindex', + 'searchindex_content', + 'searchindex_segments', + 'searchindex_segdir', + // FTS4 ready!!1 + 'searchindex_docsize', + 'searchindex_stat', + ]; + foreach ( $excluded as $t ) { + unset( $list[$t] ); + } + $list = array_flip( $list ); + sort( $list ); + + return $list; + } + + private function getColumns( $db, $table ) { + $cols = []; + $res = $db->query( "PRAGMA table_info($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $col ) { + $cols[$col->name] = $col; + } + ksort( $cols ); + + return $cols; + } + + private function getIndexes( $db, $table ) { + $indexes = []; + $res = $db->query( "PRAGMA index_list($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $index ) { + $res2 = $db->query( "PRAGMA index_info({$index->name})" ); + $this->assertNotNull( $res2 ); + $index->columns = []; + foreach ( $res2 as $col ) { + $index->columns[] = $col; + } + $indexes[$index->name] = $index; + } + ksort( $indexes ); + + return $indexes; + } + + /** + * @coversNothing + */ + public function testCaseInsensitiveLike() { + // TODO: Test this for all databases + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); + $row = $res->fetchRow(); + $this->assertFalse( (bool)$row['a'] ); + } + + /** + * @covers DatabaseSqlite::numFields + */ + public function testNumFields() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); + $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); + $this->assertTrue( $insertion, "Insertion failed" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); + + $this->assertTrue( $db->close(), "closing database" ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString + */ + public function testToString() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $toString = (string)$db; + + $this->assertContains( 'sqlite object', $toString ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes() + */ + public function testsAttributes() { + $attributes = Database::attributesFromType( 'sqlite' ); + $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ); + } +} diff --git a/tests/phpunit/languages/LanguageTest.php b/tests/phpunit/languages/LanguageTest.php index b9b8306e0c..2f6fa39b36 100644 --- a/tests/phpunit/languages/LanguageTest.php +++ b/tests/phpunit/languages/LanguageTest.php @@ -135,6 +135,18 @@ class LanguageTest extends LanguageClassesTestCase { '48 hours 0 minutes', 'formatTimePeriod() rounding (=48h), avoidseconds' ], + [ + 259199.55, + 'avoidhours', + '3 d', + 'formatTimePeriod() rounding (>48h), avoidhours' + ], + [ + 259199.55, + [ 'avoid' => 'avoidhours', 'noabbrevs' => true ], + '3 days', + 'formatTimePeriod() rounding (>48h), avoidhours' + ], [ 259199.55, 'avoidminutes', diff --git a/tests/phpunit/languages/SpecialPageAliasTest.php b/tests/phpunit/languages/SpecialPageAliasTest.php index d406c88933..cce9d0eb0f 100644 --- a/tests/phpunit/languages/SpecialPageAliasTest.php +++ b/tests/phpunit/languages/SpecialPageAliasTest.php @@ -11,7 +11,7 @@ * * @author Katie Filbert < aude.wiki@gmail.com > */ -class SpecialPageAliasTest extends MediaWikiTestCase { +class SpecialPageAliasTest extends \MediaWikiUnitTestCase { /** * @coversNothing diff --git a/tests/phpunit/languages/classes/LanguageSrTest.php b/tests/phpunit/languages/classes/LanguageSrTest.php index c9f2f3edf7..8da760237a 100644 --- a/tests/phpunit/languages/classes/LanguageSrTest.php +++ b/tests/phpunit/languages/classes/LanguageSrTest.php @@ -238,6 +238,7 @@ class LanguageSrTest extends LanguageClassesTestCase { } # #### HELPERS ##################################################### + /** *Wrapper to verify text stay the same after applying conversion * @param string $text Text to convert diff --git a/tests/phpunit/languages/classes/LanguageUzTest.php b/tests/phpunit/languages/classes/LanguageUzTest.php index 18b2031f80..abc63ee4e3 100644 --- a/tests/phpunit/languages/classes/LanguageUzTest.php +++ b/tests/phpunit/languages/classes/LanguageUzTest.php @@ -60,6 +60,7 @@ class LanguageUzTest extends LanguageClassesTestCase { } # #### HELPERS ##################################################### + /** * Wrapper to verify text stay the same after applying conversion * @param string $text Text to convert diff --git a/tests/phpunit/maintenance/DumpAsserter.php b/tests/phpunit/maintenance/DumpAsserter.php index ad33f6e154..e8c1cd6090 100644 --- a/tests/phpunit/maintenance/DumpAsserter.php +++ b/tests/phpunit/maintenance/DumpAsserter.php @@ -137,6 +137,34 @@ class DumpAsserter { } } + /** + * Asserts that the xml reader is at an element of given name, and that element + * is an empty tag. + * + * @param string $name The name of the element to check for + * (e.g.: "text" for ) + * @param bool $skip (optional) if true, skip past the found element + * @param bool $skip_ws (optional) if true, also skip past white spaces that trail the + * closing element. + */ + public function assertEmptyNode( $name, $skip = true, $skip_ws = true ) { + $this->assertNodeStart( $name, false ); + Assert::assertFalse( $this->xml->hasValue, "$name tag has content" ); + + if ( $skip ) { + Assert::assertTrue( $this->xml->read(), "Skipping $name tag" ); + if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT ) + && ( $this->xml->name == $name ) + ) { + $this->xml->read(); + } + + if ( $skip_ws ) { + $this->skipWhitespace(); + } + } + } + /** * Asserts that the xml reader is at an closing element of given name, and optionally * skips past it. @@ -246,6 +274,11 @@ class DumpAsserter { $this->assertTextNode( "comment", $summary ); $this->skipWhitespace(); + if ( $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11 ) { + $this->assertTextNode( "origin", false ); + $this->skipWhitespace(); + } + $this->assertTextNode( "model", $model ); $this->skipWhitespace(); @@ -258,9 +291,16 @@ class DumpAsserter { $this->assertText( $id, $text_id, $text_bytes, $text ); } else { $text_found = false; + if ( $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11 ) { + Assert::fail( 'Missing text node' ); + } } - $this->assertTextNode( "sha1", $text_sha1 ); + if ( $text_sha1 ) { + $this->assertTextNode( "sha1", $text_sha1 ); + } else { + $this->assertEmptyNode( "sha1" ); + } if ( !$text_found ) { $this->assertText( $id, $text_id, $text_bytes, $text ); @@ -278,17 +318,9 @@ class DumpAsserter { } if ( $text === false ) { - // Testing for a stub Assert::assertEquals( $this->xml->getAttribute( "id" ), $text_id, "Text id of revision " . $id ); - Assert::assertFalse( $this->xml->hasValue, "Revision has text" ); - Assert::assertTrue( $this->xml->read(), "Skipping text start tag" ); - if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT ) - && ( $this->xml->name == "text" ) - ) { - $this->xml->read(); - } - $this->skipWhitespace(); + $this->assertEmptyNode( "text" ); } else { // Testing for a real dump Assert::assertTrue( $this->xml->read(), "Skipping text start tag" ); diff --git a/tests/phpunit/maintenance/backup_PageTest.php b/tests/phpunit/maintenance/backup_PageTest.php index 17c8757b3c..7a78e524a5 100644 --- a/tests/phpunit/maintenance/backup_PageTest.php +++ b/tests/phpunit/maintenance/backup_PageTest.php @@ -5,8 +5,11 @@ namespace MediaWiki\Tests\Maintenance; use DumpBackup; use Exception; use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRecord; use MediaWikiTestCase; use MWException; +use RequestContext; +use RevisionDeleter; use Title; use WikiExporter; use Wikimedia\Rdbms\IDatabase; @@ -77,6 +80,17 @@ class BackupDumperPageTest extends DumpTestCase { "BackupDumperTestP2Summary4 extra " ); $this->pageId2 = $page->getId(); + $revDel = RevisionDeleter::createList( + 'revision', + RequestContext::getMain(), + $this->pageTitle2, + [ $this->revId2_2 ] + ); + $revDel->setVisibility( [ + 'value' => [ RevisionRecord::DELETED_TEXT => 1 ], + 'comment' => 'testing!' + ] ); + $this->pageTitle3 = Title::newFromText( 'BackupDumperTestP3', $this->namespace ); $page = WikiPage::factory( $this->pageTitle3 ); list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page, @@ -232,10 +246,10 @@ class BackupDumperPageTest extends DumpTestCase { $asserter->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", - $this->textId2_2, - 23, - "b7vj5ks32po5m1z1t1br4o7scdwwy95", - "BackupDumperTestP2Text2", + null, // deleted! + false, // deleted! + null, // deleted! + false, // deleted! $this->revId2_1 ); $asserter->assertRevision( @@ -346,10 +360,10 @@ class BackupDumperPageTest extends DumpTestCase { $asserter->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", - $this->textId2_2, - 23, - "b7vj5ks32po5m1z1t1br4o7scdwwy95", - false, + null, // deleted! + false, // deleted! + null, // deleted! + false, // deleted! $this->revId2_1 ); $asserter->assertRevision( @@ -622,10 +636,10 @@ class BackupDumperPageTest extends DumpTestCase { $asserter->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", - $this->textId2_2, - 23, - "b7vj5ks32po5m1z1t1br4o7scdwwy95", - false, + null, // deleted! + false, // deleted! + null, // deleted! + false, // deleted! $this->revId2_1 ); $asserter->assertRevision( diff --git a/tests/phpunit/maintenance/categoriesRdfTest.php b/tests/phpunit/maintenance/categoriesRdfTest.php index 5068e7011f..be38affc5a 100644 --- a/tests/phpunit/maintenance/categoriesRdfTest.php +++ b/tests/phpunit/maintenance/categoriesRdfTest.php @@ -58,7 +58,7 @@ class CategoriesRdfTest extends MediaWikiLangTestCase { 'wgServer' => 'http://acme.test', 'wgCanonicalServer' => 'http://acme.test', 'wgArticlePath' => '/wiki/$1', - 'wgRightsUrl' => '//creativecommons.org/licenses/by-sa/3.0/', + 'wgRightsUrl' => 'https://creativecommons.org/licenses/by-sa/3.0/', ] ); $dumpScript = diff --git a/tests/phpunit/mocks/search/MockSearchResult.php b/tests/phpunit/mocks/search/MockSearchResult.php index d92d39a634..e92eb5660c 100644 --- a/tests/phpunit/mocks/search/MockSearchResult.php +++ b/tests/phpunit/mocks/search/MockSearchResult.php @@ -7,6 +7,7 @@ class MockSearchResult extends SearchResult { public function isMissingRevision() { return $this->isMissingRevision; } + public function setMissingRevision( $isMissingRevision ) { $this->isMissingRevision = $isMissingRevision; return $this; diff --git a/tests/phpunit/mocks/session/DummySessionProvider.php b/tests/phpunit/mocks/session/DummySessionProvider.php index dcbb101a07..69f9c389e7 100644 --- a/tests/phpunit/mocks/session/DummySessionProvider.php +++ b/tests/phpunit/mocks/session/DummySessionProvider.php @@ -1,4 +1,5 @@ assertSame( @@ -199,7 +198,7 @@ class AutoLoaderStructureTest extends MediaWikiTestCase { } public function testAutoloadOrder() { - $path = realpath( __DIR__ . '/../../..' ); + $path = __DIR__ . '/../../..'; $oldAutoload = file_get_contents( $path . '/autoload.php' ); $generator = new AutoloadGenerator( $path, 'local' ); $generator->setPsr4Namespaces( AutoLoader::getAutoloadNamespaces() ); diff --git a/tests/phpunit/structure/AvailableRightsTest.php b/tests/phpunit/structure/AvailableRightsTest.php index 57b063d5ba..2a6575a9ec 100644 --- a/tests/phpunit/structure/AvailableRightsTest.php +++ b/tests/phpunit/structure/AvailableRightsTest.php @@ -35,9 +35,6 @@ class AvailableRightsTest extends PHPUnit\Framework\TestCase { return $rights; } - /** - * @coversNothing - */ public function testAvailableRights() { $missingRights = array_diff( $this->getAllVisibleRights(), @@ -69,8 +66,6 @@ class AvailableRightsTest extends PHPUnit\Framework\TestCase { * Test, if for all rights a right- message exist, * which is used on Special:ListGroupRights as help text * Extensions and core - * - * @coversNothing */ public function testAllRightsWithMessage() { $this->checkMessagesExist( 'right-' ); diff --git a/tests/phpunit/structure/ContentHandlerSanityTest.php b/tests/phpunit/structure/ContentHandlerSanityTest.php index c75a9d02a3..c8bcd60de3 100644 --- a/tests/phpunit/structure/ContentHandlerSanityTest.php +++ b/tests/phpunit/structure/ContentHandlerSanityTest.php @@ -32,7 +32,6 @@ class ContentHandlerSanityTest extends MediaWikiTestCase { } /** - * @coversNothing * @dataProvider provideHandlers * @param ContentHandler $handler */ diff --git a/tests/phpunit/structure/DatabaseIntegrationTest.php b/tests/phpunit/structure/DatabaseIntegrationTest.php index 9c0a73de8d..b0c1c8f1f5 100644 --- a/tests/phpunit/structure/DatabaseIntegrationTest.php +++ b/tests/phpunit/structure/DatabaseIntegrationTest.php @@ -5,7 +5,6 @@ use Wikimedia\Rdbms\Database; /** * @group Database - * @coversNothing */ class DatabaseIntegrationTest extends MediaWikiTestCase { /** diff --git a/tests/phpunit/structure/ExtensionJsonValidationTest.php b/tests/phpunit/structure/ExtensionJsonValidationTest.php index dea8f5a541..60c97ccfac 100644 --- a/tests/phpunit/structure/ExtensionJsonValidationTest.php +++ b/tests/phpunit/structure/ExtensionJsonValidationTest.php @@ -19,8 +19,6 @@ /** * Validates all loaded extensions and skins using the ExtensionRegistry * against the extension.json schema in the docs/ folder. - * - * @coversNothing */ class ExtensionJsonValidationTest extends PHPUnit\Framework\TestCase { diff --git a/tests/phpunit/structure/PasswordPolicyStructureTest.php b/tests/phpunit/structure/PasswordPolicyStructureTest.php index 60ce575e4c..d7f865d2e7 100644 --- a/tests/phpunit/structure/PasswordPolicyStructureTest.php +++ b/tests/phpunit/structure/PasswordPolicyStructureTest.php @@ -1,8 +1,5 @@ $module ) { - if ( $module->isRaw() ) { + if ( $module instanceof ResourceLoaderStartUpModule ) { $illegalDeps[] = $moduleName; } } diff --git a/tests/phpunit/structure/SpecialPageFatalTest.php b/tests/phpunit/structure/SpecialPageFatalTest.php index 026b903250..3fa31fe8b5 100644 --- a/tests/phpunit/structure/SpecialPageFatalTest.php +++ b/tests/phpunit/structure/SpecialPageFatalTest.php @@ -11,7 +11,6 @@ use MediaWiki\MediaWikiServices; * * @since 1.32 * @author Addshore - * @coversNothing */ class SpecialPageFatalTest extends MediaWikiTestCase { public function provideSpecialPages() { diff --git a/tests/phpunit/structure/StructureTest.php b/tests/phpunit/structure/StructureTest.php index 97bed4c7a8..412ee991f9 100644 --- a/tests/phpunit/structure/StructureTest.php +++ b/tests/phpunit/structure/StructureTest.php @@ -9,7 +9,6 @@ class StructureTest extends MediaWikiTestCase { * Verify all files that appear to be tests have file names ending in * Test. If the file names do not end in Test, they will not be run. * @group medium - * @coversNothing */ public function testUnitTestFileNamesEndWithTest() { // realpath() also normalizes directory separator on windows for prefix compares diff --git a/tests/phpunit/suite.xml b/tests/phpunit/suite.xml index de68fec17e..d7d3c6130b 100644 --- a/tests/phpunit/suite.xml +++ b/tests/phpunit/suite.xml @@ -1,5 +1,5 @@ - documentation + + unit + - Utility Broken - Stub @@ -76,4 +77,18 @@ + + + + + + 50 + + + 50 + + + + + diff --git a/tests/phpunit/suites/ParserIntegrationTest.php b/tests/phpunit/suites/ParserIntegrationTest.php index 91653b5d86..0f06d3979a 100644 --- a/tests/phpunit/suites/ParserIntegrationTest.php +++ b/tests/phpunit/suites/ParserIntegrationTest.php @@ -1,4 +1,5 @@ hideDeprecated( 'MediaWikiTestCase::stashMwGlobals' ); - $this->stashMwGlobals( $globalKey ); - $GLOBALS[$globalKey] = $newValue; - $this->assertEquals( - $newValue, - $GLOBALS[$globalKey], - 'Global failed to correctly set' - ); - - $this->tearDown(); - - $this->assertEquals( - self::$startGlobals[$globalKey], - $GLOBALS[$globalKey], - 'Global failed to be restored on tearDown' - ); - } - - /** - * @covers MediaWikiTestCase::stashMwGlobals + * @covers MediaWikiTestCase::setMwGlobals * @covers MediaWikiTestCase::tearDown */ public function testSetNonExistentGlobalsAreUnsetOnTearDown() { @@ -191,7 +167,7 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase { $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); $lb = $lbFactory->newMainLB(); - $db = $lb->getConnection( DB_REPLICA, DBO_TRX ); + $db = $lb->getConnection( DB_REPLICA ); // sanity $this->assertNotSame( $this->db, $db ); @@ -208,4 +184,43 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase { $this->assertSame( 'TEST', $value, 'Copied Data' ); } + public function testResetServices() { + $services = MediaWikiServices::getInstance(); + + // override a service instance + $myReadOnlyMode = $this->getMockBuilder( ReadOnlyMode::class ) + ->disableOriginalConstructor() + ->getMock(); + $this->setService( 'ReadOnlyMode', $myReadOnlyMode ); + + // sanity check + $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) ); + + // define a custom service + $services->defineService( + '_TEST_ResetService_Dummy', + function ( MediaWikiServices $services ) { + $conf = $services->getMainConfig(); + return (object)[ 'lang' => $conf->get( 'LanguageCode' ) ]; + } + ); + + // sanity check + $lang = $services->getMainConfig()->get( 'LanguageCode' ); + $dummy = $services->getService( '_TEST_ResetService_Dummy' ); + $this->assertSame( $lang, $dummy->lang ); + + // the actual test: change config, reset services. + $this->setMwGlobals( 'wgLanguageCode', 'qqx' ); + $this->resetServices(); + + // the overridden service instance should still be there + $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) ); + + // our custom service should have been re-created with the new language code + $dummy2 = $services->getService( '_TEST_ResetService_Dummy' ); + $this->assertNotSame( $dummy2, $dummy ); + $this->assertSame( 'qqx', $dummy2->lang ); + } + } diff --git a/tests/phpunit/unit/includes/FauxResponseTest.php b/tests/phpunit/unit/includes/FauxResponseTest.php new file mode 100644 index 0000000000..5e208aceda --- /dev/null +++ b/tests/phpunit/unit/includes/FauxResponseTest.php @@ -0,0 +1,146 @@ +response = new FauxResponse; + } + + /** + * @covers FauxResponse::setCookie + * @covers FauxResponse::getCookie + * @covers FauxResponse::getCookieData + * @covers FauxResponse::getCookies + */ + public function testCookie() { + $expire = time() + 100; + $cookie = [ + 'value' => 'val', + 'path' => '/path', + 'domain' => 'domain', + 'secure' => true, + 'httpOnly' => false, + 'raw' => false, + 'expire' => $expire, + ]; + + $this->assertEquals( null, $this->response->getCookie( 'xkey' ), 'Non-existing cookie' ); + $this->response->setCookie( 'key', 'val', $expire, [ + 'prefix' => 'x', + 'path' => '/path', + 'domain' => 'domain', + 'secure' => 1, + 'httpOnly' => 0, + ] ); + $this->assertEquals( 'val', $this->response->getCookie( 'xkey' ), 'Existing cookie' ); + $this->assertEquals( $cookie, $this->response->getCookieData( 'xkey' ), + 'Existing cookie (data)' ); + $this->assertEquals( [ 'xkey' => $cookie ], $this->response->getCookies(), + 'Existing cookies' ); + } + + /** + * @covers FauxResponse::getheader + * @covers FauxResponse::header + */ + public function testHeader() { + $this->assertEquals( null, $this->response->getHeader( 'Location' ), 'Non-existing header' ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getHeader( 'Location' ), + 'Set header' + ); + + $this->response->header( 'Location: http://127.0.0.1/' ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getHeader( 'Location' ), + 'Same header' + ); + + $this->response->header( 'Location: http://127.0.0.2/', false ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getHeader( 'Location' ), + 'Same header with override disabled' + ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getHeader( 'LOCATION' ), + 'Get header case insensitive' + ); + } + + /** + * @covers FauxResponse::getStatusCode + */ + public function testResponseCode() { + $this->response->header( 'HTTP/1.1 200' ); + $this->assertEquals( 200, $this->response->getStatusCode(), 'Header with no message' ); + + $this->response->header( 'HTTP/1.x 201' ); + $this->assertEquals( + 201, + $this->response->getStatusCode(), + 'Header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.1 202 OK' ); + $this->assertEquals( 202, $this->response->getStatusCode(), 'Normal header' ); + + $this->response->header( 'HTTP/1.x 203 OK' ); + $this->assertEquals( + 203, + $this->response->getStatusCode(), + 'Normal header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.x 204 OK', false, 205 ); + $this->assertEquals( + 205, + $this->response->getStatusCode(), + 'Third parameter overrides the HTTP/... header' + ); + + $this->response->statusHeader( 210 ); + $this->assertEquals( + 210, + $this->response->getStatusCode(), + 'Handle statusHeader method' + ); + + $this->response->header( 'Location: http://localhost/', false, 206 ); + $this->assertEquals( + 206, + $this->response->getStatusCode(), + 'Third parameter with another header' + ); + } +} diff --git a/tests/phpunit/unit/includes/FormOptionsInitializationTest.php b/tests/phpunit/unit/includes/FormOptionsInitializationTest.php new file mode 100644 index 0000000000..708956d260 --- /dev/null +++ b/tests/phpunit/unit/includes/FormOptionsInitializationTest.php @@ -0,0 +1,70 @@ +object = TestingAccessWrapper::newFromObject( new FormOptions() ); + } + + /** + * @covers FormOptions::add + */ + public function testAddStringOption() { + $this->object->add( 'foo', 'string value' ); + $this->assertEquals( + [ + 'foo' => [ + 'default' => 'string value', + 'consumed' => false, + 'type' => FormOptions::STRING, + 'value' => null, + ] + ], + $this->object->options + ); + } + + /** + * @covers FormOptions::add + */ + public function testAddIntegers() { + $this->object->add( 'one', 1 ); + $this->object->add( 'negone', -1 ); + $this->assertEquals( + [ + 'negone' => [ + 'default' => -1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ], + 'one' => [ + 'default' => 1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ] + ], + $this->object->options + ); + } +} diff --git a/tests/phpunit/unit/includes/FormOptionsTest.php b/tests/phpunit/unit/includes/FormOptionsTest.php new file mode 100644 index 0000000000..c14595bc4a --- /dev/null +++ b/tests/phpunit/unit/includes/FormOptionsTest.php @@ -0,0 +1,105 @@ +object = new FormOptions; + $this->object->add( 'string1', 'string one' ); + $this->object->add( 'string2', 'string two' ); + $this->object->add( 'integer', 0 ); + $this->object->add( 'float', 0.0 ); + $this->object->add( 'intnull', 0, FormOptions::INTNULL ); + } + + /** Helpers for testGuessType() */ + /* @{ */ + private function assertGuessBoolean( $data ) { + $this->guess( FormOptions::BOOL, $data ); + } + + private function assertGuessInt( $data ) { + $this->guess( FormOptions::INT, $data ); + } + + private function assertGuessFloat( $data ) { + $this->guess( FormOptions::FLOAT, $data ); + } + + private function assertGuessString( $data ) { + $this->guess( FormOptions::STRING, $data ); + } + + private function assertGuessArray( $data ) { + $this->guess( FormOptions::ARR, $data ); + } + + /** Generic helper */ + private function guess( $expected, $data ) { + $this->assertEquals( + $expected, + FormOptions::guessType( $data ) + ); + } + + /* @} */ + + /** + * Reuse helpers above assertGuessBoolean assertGuessInt assertGuessString + * @covers FormOptions::guessType + */ + public function testGuessTypeDetection() { + $this->assertGuessBoolean( true ); + $this->assertGuessBoolean( false ); + + $this->assertGuessInt( 0 ); + $this->assertGuessInt( -5 ); + $this->assertGuessInt( 5 ); + $this->assertGuessInt( 0x0F ); + + $this->assertGuessFloat( 0.0 ); + $this->assertGuessFloat( 1.5 ); + $this->assertGuessFloat( 1e3 ); + + $this->assertGuessString( 'true' ); + $this->assertGuessString( 'false' ); + $this->assertGuessString( '5' ); + $this->assertGuessString( '0' ); + $this->assertGuessString( '1.5' ); + + $this->assertGuessArray( [ 'foo' ] ); + } + + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnNullThrowException() { + $this->object->guessType( null ); + } +} diff --git a/tests/phpunit/unit/includes/LicensesTest.php b/tests/phpunit/unit/includes/LicensesTest.php new file mode 100644 index 0000000000..e5a6baeff6 --- /dev/null +++ b/tests/phpunit/unit/includes/LicensesTest.php @@ -0,0 +1,25 @@ + 'FooField', + 'type' => 'select', + 'section' => 'description', + 'id' => 'wpLicense', + 'label' => 'A label text', # Note can't test label-message because $wgOut is not defined + 'name' => 'AnotherName', + 'licenses' => $str, + ] ); + $this->assertThat( $lc, $this->isInstanceOf( Licenses::class ) ); + } +} diff --git a/tests/phpunit/unit/includes/MediaWikiVersionFetcherTest.php b/tests/phpunit/unit/includes/MediaWikiVersionFetcherTest.php new file mode 100644 index 0000000000..dfdbfa7344 --- /dev/null +++ b/tests/phpunit/unit/includes/MediaWikiVersionFetcherTest.php @@ -0,0 +1,21 @@ + + */ +class MediaWikiVersionFetcherTest extends \MediaWikiUnitTestCase { + + public function testReturnsResult() { + global $wgVersion; + $versionFetcher = new MediaWikiVersionFetcher(); + $this->assertSame( $wgVersion, $versionFetcher->fetchVersion() ); + } + +} diff --git a/tests/phpunit/unit/includes/Rest/EntryPointTest.php b/tests/phpunit/unit/includes/Rest/EntryPointTest.php new file mode 100644 index 0000000000..e1f2c883a5 --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/EntryPointTest.php @@ -0,0 +1,89 @@ +getMockBuilder( WebResponse::class ) + ->setMethods( [ 'header' ] ) + ->getMock(); + } + + public static function mockHandlerHeader() { + return new class extends Handler { + public function execute() { + $response = $this->getResponseFactory()->create(); + $response->setHeader( 'Foo', 'Bar' ); + return $response; + } + }; + } + + public function testHeader() { + $webResponse = $this->createWebResponse(); + $webResponse->expects( $this->any() ) + ->method( 'header' ) + ->withConsecutive( + [ 'HTTP/1.1 200 OK', true, null ], + [ 'Foo: Bar', true, null ] + ); + + $entryPoint = new EntryPoint( + new RequestData( [ 'uri' => new Uri( '/rest/mock/EntryPoint/header' ) ] ), + $webResponse, + $this->createRouter() ); + $entryPoint->execute(); + $this->assertTrue( true ); + } + + public static function mockHandlerBodyRewind() { + return new class extends Handler { + public function execute() { + $response = $this->getResponseFactory()->create(); + $stream = new Stream( fopen( 'php://memory', 'w+' ) ); + $stream->write( 'hello' ); + $response->setBody( $stream ); + return $response; + } + }; + } + + /** + * Make sure EntryPoint rewinds a seekable body stream before reading. + */ + public function testBodyRewind() { + $entryPoint = new EntryPoint( + new RequestData( [ 'uri' => new Uri( '/rest/mock/EntryPoint/bodyRewind' ) ] ), + $this->createWebResponse(), + $this->createRouter() ); + ob_start(); + $entryPoint->execute(); + $this->assertSame( 'hello', ob_get_clean() ); + } + +} diff --git a/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php b/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php new file mode 100644 index 0000000000..c68273b758 --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php @@ -0,0 +1,80 @@ + [ + [ + 'method' => 'GET', + 'uri' => self::makeUri( '/user/Tim/hello' ), + ], + [ + 'statusCode' => 200, + 'reasonPhrase' => 'OK', + 'protocolVersion' => '1.1', + 'body' => '{"message":"Hello, Tim!"}', + ], + ], + 'method not allowed' => [ + [ + 'method' => 'POST', + 'uri' => self::makeUri( '/user/Tim/hello' ), + ], + [ + 'statusCode' => 405, + 'reasonPhrase' => 'Method Not Allowed', + 'protocolVersion' => '1.1', + 'body' => '{"httpCode":405,"httpReason":"Method Not Allowed"}', + ], + ], + ]; + } + + private static function makeUri( $path ) { + return new Uri( "http://www.example.com/rest$path" ); + } + + /** @dataProvider provideTestViaRouter */ + public function testViaRouter( $requestInfo, $responseInfo ) { + $router = new Router( + [ __DIR__ . '/../testRoutes.json' ], + [], + '/rest', + new EmptyBagOStuff(), + new ResponseFactory() ); + $request = new RequestData( $requestInfo ); + $response = $router->execute( $request ); + if ( isset( $responseInfo['statusCode'] ) ) { + $this->assertSame( $responseInfo['statusCode'], $response->getStatusCode() ); + } + if ( isset( $responseInfo['reasonPhrase'] ) ) { + $this->assertSame( $responseInfo['reasonPhrase'], $response->getReasonPhrase() ); + } + if ( isset( $responseInfo['protocolVersion'] ) ) { + $this->assertSame( $responseInfo['protocolVersion'], $response->getProtocolVersion() ); + } + if ( isset( $responseInfo['body'] ) ) { + $this->assertSame( $responseInfo['body'], $response->getBody()->getContents() ); + } + $this->assertSame( + [], + array_diff( array_keys( $responseInfo ), [ + 'statusCode', + 'reasonPhrase', + 'protocolVersion', + 'body' + ] ), + '$responseInfo may not contain unknown keys' ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/HeaderContainerTest.php b/tests/phpunit/unit/includes/Rest/HeaderContainerTest.php new file mode 100644 index 0000000000..e65251e350 --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/HeaderContainerTest.php @@ -0,0 +1,171 @@ + [ + [ + [ 'Test', 'foo' ] + ], + [ 'Test' => [ 'foo' ] ], + [ 'Test' => 'foo' ] + ], + 'replace' => [ + [ + [ 'Test', 'foo' ], + [ 'Test', 'bar' ], + ], + [ 'Test' => [ 'bar' ] ], + [ 'Test' => 'bar' ], + ], + 'array value' => [ + [ + [ 'Test', [ '1', '2' ] ], + [ 'Test', [ '3', '4' ] ], + ], + [ 'Test' => [ '3', '4' ] ], + [ 'Test' => '3, 4' ] + ], + 'preserve most recent case' => [ + [ + [ 'test', 'foo' ], + [ 'tesT', 'bar' ], + ], + [ 'tesT' => [ 'bar' ] ], + [ 'tesT' => 'bar' ] + ], + 'empty' => [ [], [], [] ], + ]; + } + + /** @dataProvider provideSetHeader */ + public function testSetHeader( $setOps, $headers, $lines ) { + $hc = new HeaderContainer; + foreach ( $setOps as list( $name, $value ) ) { + $hc->setHeader( $name, $value ); + } + $this->assertSame( $headers, $hc->getHeaders() ); + $this->assertSame( $lines, $hc->getHeaderLines() ); + } + + public static function provideAddHeader() { + return [ + 'simple' => [ + [ + [ 'Test', 'foo' ] + ], + [ 'Test' => [ 'foo' ] ], + [ 'Test' => 'foo' ] + ], + 'add' => [ + [ + [ 'Test', 'foo' ], + [ 'Test', 'bar' ], + ], + [ 'Test' => [ 'foo', 'bar' ] ], + [ 'Test' => 'foo, bar' ], + ], + 'array value' => [ + [ + [ 'Test', [ '1', '2' ] ], + [ 'Test', [ '3', '4' ] ], + ], + [ 'Test' => [ '1', '2', '3', '4' ] ], + [ 'Test' => '1, 2, 3, 4' ] + ], + 'preserve original case' => [ + [ + [ 'Test', 'foo' ], + [ 'tesT', 'bar' ], + ], + [ 'Test' => [ 'foo', 'bar' ] ], + [ 'Test' => 'foo, bar' ] + ], + ]; + } + + /** @dataProvider provideAddHeader */ + public function testAddHeader( $addOps, $headers, $lines ) { + $hc = new HeaderContainer; + foreach ( $addOps as list( $name, $value ) ) { + $hc->addHeader( $name, $value ); + } + $this->assertSame( $headers, $hc->getHeaders() ); + $this->assertSame( $lines, $hc->getHeaderLines() ); + } + + public static function provideRemoveHeader() { + return [ + 'simple' => [ + [ [ 'Test', 'foo' ] ], + [ 'Test' ], + [], + [] + ], + 'case mismatch' => [ + [ [ 'Test', 'foo' ] ], + [ 'tesT' ], + [], + [] + ], + 'remove nonexistent' => [ + [ [ 'A', '1' ] ], + [ 'B' ], + [ 'A' => [ '1' ] ], + [ 'A' => '1' ] + ], + ]; + } + + /** @dataProvider provideRemoveHeader */ + public function testRemoveHeader( $addOps, $removeOps, $headers, $lines ) { + $hc = new HeaderContainer; + foreach ( $addOps as list( $name, $value ) ) { + $hc->addHeader( $name, $value ); + } + foreach ( $removeOps as $name ) { + $hc->removeHeader( $name ); + } + $this->assertSame( $headers, $hc->getHeaders() ); + $this->assertSame( $lines, $hc->getHeaderLines() ); + } + + public function testHasHeader() { + $hc = new HeaderContainer; + $hc->addHeader( 'A', '1' ); + $hc->addHeader( 'B', '2' ); + $hc->addHeader( 'C', '3' ); + $hc->removeHeader( 'B' ); + $hc->removeHeader( 'c' ); + $this->assertTrue( $hc->hasHeader( 'A' ) ); + $this->assertTrue( $hc->hasHeader( 'a' ) ); + $this->assertFalse( $hc->hasHeader( 'B' ) ); + $this->assertFalse( $hc->hasHeader( 'c' ) ); + $this->assertFalse( $hc->hasHeader( 'C' ) ); + } + + public function testGetRawHeaderLines() { + $hc = new HeaderContainer; + $hc->addHeader( 'A', '1' ); + $hc->addHeader( 'a', '2' ); + $hc->addHeader( 'b', '3' ); + $hc->addHeader( 'Set-Cookie', 'x' ); + $hc->addHeader( 'SET-cookie', 'y' ); + $this->assertSame( + [ + 'A: 1, 2', + 'b: 3', + 'Set-Cookie: x', + 'Set-Cookie: y', + ], + $hc->getRawHeaderLines() + ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php b/tests/phpunit/unit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php new file mode 100644 index 0000000000..f56024cfdf --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php @@ -0,0 +1,76 @@ + [], 'userData' => 0 ] ], + [ '/b', false ], + [ '/b/1', [ 'params' => [ 'x' => '1' ], 'userData' => 1 ] ], + [ '/c/1/d', [ 'params' => [ 'x' => '1' ], 'userData' => 2 ] ], + [ '/c/1/e', [ 'params' => [ 'x' => '1' ], 'userData' => 3 ] ], + [ '/c/000/e', [ 'params' => [ 'x' => '000' ], 'userData' => 3 ] ], + [ '/c/1/f', false ], + [ '/c//e', [ 'params' => [ 'x' => '' ], 'userData' => 3 ] ], + [ '/c///e', false ], + ]; + } + + public function createNormalRouter() { + $pm = new PathMatcher; + foreach ( self::$normalRoutes as $i => $route ) { + $pm->add( $route, $i ); + } + return $pm; + } + + /** @dataProvider provideConflictingRoutes */ + public function testAddConflict( $attempt, $expectedUserData, $expectedTemplate ) { + $pm = $this->createNormalRouter(); + $actualTemplate = null; + $actualUserData = null; + try { + $pm->add( $attempt, 'conflict' ); + } catch ( PathConflict $pc ) { + $actualTemplate = $pc->existingTemplate; + $actualUserData = $pc->existingUserData; + } + $this->assertSame( $expectedUserData, $actualUserData ); + $this->assertSame( $expectedTemplate, $actualTemplate ); + } + + /** @dataProvider provideMatch */ + public function testMatch( $path, $expectedResult ) { + $pm = $this->createNormalRouter(); + $result = $pm->match( $path ); + $this->assertSame( $expectedResult, $result ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/StringStreamTest.php b/tests/phpunit/unit/includes/Rest/StringStreamTest.php new file mode 100644 index 0000000000..1e72239d1d --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/StringStreamTest.php @@ -0,0 +1,130 @@ +write( $input ); + $ss->seek( 1 ); + $ss->seek( $offset, $whence ); + $destStream = fopen( 'php://memory', 'w+' ); + $ss->copyToStream( $destStream ); + fseek( $destStream, 0 ); + $result = stream_get_contents( $destStream ); + $this->assertSame( $expected, $result ); + } + + public function testGetSize() { + $ss = new StringStream; + $this->assertSame( 0, $ss->getSize() ); + $ss->write( "hello" ); + $this->assertSame( 5, $ss->getSize() ); + $ss->rewind(); + $this->assertSame( 5, $ss->getSize() ); + } + + public function testTell() { + $ss = new StringStream; + $this->assertSame( $ss->tell(), 0 ); + $ss->write( "abc" ); + $this->assertSame( $ss->tell(), 3 ); + $ss->seek( 0 ); + $ss->read( 1 ); + $this->assertSame( $ss->tell(), 1 ); + } + + public function testEof() { + $ss = new StringStream( 'abc' ); + $this->assertFalse( $ss->eof() ); + $ss->read( 1 ); + $this->assertFalse( $ss->eof() ); + $ss->read( 1 ); + $this->assertFalse( $ss->eof() ); + $ss->read( 1 ); + $this->assertTrue( $ss->eof() ); + $ss->rewind(); + $this->assertFalse( $ss->eof() ); + } + + public function testIsSeekable() { + $ss = new StringStream; + $this->assertTrue( $ss->isSeekable() ); + } + + public function testIsReadable() { + $ss = new StringStream; + $this->assertTrue( $ss->isReadable() ); + } + + public function testIsWritable() { + $ss = new StringStream; + $this->assertTrue( $ss->isWritable() ); + } + + public function testSeekWrite() { + $ss = new StringStream; + $this->assertSame( '', (string)$ss ); + $ss->write( 'a' ); + $this->assertSame( 'a', (string)$ss ); + $ss->write( 'b' ); + $this->assertSame( 'ab', (string)$ss ); + $ss->seek( 1 ); + $ss->write( 'c' ); + $this->assertSame( 'ac', (string)$ss ); + } + + /** @dataProvider provideSeekGetContents */ + public function testSeekGetContents( $input, $offset, $whence, $expected ) { + $ss = new StringStream( $input ); + $ss->seek( 1 ); + $ss->seek( $offset, $whence ); + $this->assertSame( $expected, $ss->getContents() ); + } + + public static function provideSeekRead() { + return [ + [ 'abcde', 0, SEEK_SET, 1, 'a' ], + [ 'abcde', 0, SEEK_SET, 2, 'ab' ], + [ 'abcde', 4, SEEK_SET, 2, 'e' ], + [ 'abcde', 5, SEEK_SET, 1, '' ], + [ 'abcde', 1, SEEK_CUR, 1, 'c' ], + [ 'abcde', 0, SEEK_END, 1, '' ], + [ 'abcde', -1, SEEK_END, 1, 'e' ], + ]; + } + + /** @dataProvider provideSeekRead */ + public function testSeekRead( $input, $offset, $whence, $length, $expected ) { + $ss = new StringStream( $input ); + $ss->seek( 1 ); + $ss->seek( $offset, $whence ); + $this->assertSame( $expected, $ss->read( $length ) ); + } + + /** @expectedException \InvalidArgumentException */ + public function testReadBeyondEnd() { + $ss = new StringStream( 'abc' ); + $ss->seek( 1, SEEK_END ); + } + + /** @expectedException \InvalidArgumentException */ + public function testReadBeforeStart() { + $ss = new StringStream( 'abc' ); + $ss->seek( -1 ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/testRoutes.json b/tests/phpunit/unit/includes/Rest/testRoutes.json new file mode 100644 index 0000000000..7e43bb0c5d --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/testRoutes.json @@ -0,0 +1,14 @@ +[ + { + "path": "/user/{name}/hello", + "class": "MediaWiki\\Rest\\Handler\\HelloHandler" + }, + { + "path": "/mock/EntryPoint/header", + "factory": "MediaWiki\\Tests\\Rest\\EntryPointTest::mockHandlerHeader" + }, + { + "path": "/mock/EntryPoint/bodyRewind", + "factory": "MediaWiki\\Tests\\Rest\\EntryPointTest::mockHandlerBodyRewind" + } +] diff --git a/tests/phpunit/unit/includes/Revision/FallbackSlotRoleHandlerTest.php b/tests/phpunit/unit/includes/Revision/FallbackSlotRoleHandlerTest.php new file mode 100644 index 0000000000..17b3504952 --- /dev/null +++ b/tests/phpunit/unit/includes/Revision/FallbackSlotRoleHandlerTest.php @@ -0,0 +1,72 @@ +createMock( Title::class ); + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::__construct + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getRole() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getNameMessageKey() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getDefaultModel() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getOutputLayoutHints() + */ + public function testConstruction() { + $handler = new FallbackSlotRoleHandler( 'foo' ); + $this->assertSame( 'foo', $handler->getRole() ); + $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); + + $title = $this->makeBlankTitleObject(); + $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title ) ); + + $hints = $handler->getOutputLayoutHints(); + $this->assertArrayHasKey( 'display', $hints ); + $this->assertArrayHasKey( 'region', $hints ); + $this->assertArrayHasKey( 'placement', $hints ); + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedModel() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + // For the fallback handler, no models are allowed + $title = $this->makeBlankTitleObject(); + $this->assertFalse( $handler->isAllowedModel( 'FooModel', $title ) ); + $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedOn() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + $title = $this->makeBlankTitleObject(); + $this->assertFalse( $handler->isAllowedOn( $title ) ); + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::supportsArticleCount() + */ + public function testSupportsArticleCount() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + $this->assertFalse( $handler->supportsArticleCount() ); + } + +} diff --git a/tests/phpunit/unit/includes/Revision/RevisionStoreFactoryTest.php b/tests/phpunit/unit/includes/Revision/RevisionStoreFactoryTest.php new file mode 100644 index 0000000000..8e8fbd760f --- /dev/null +++ b/tests/phpunit/unit/includes/Revision/RevisionStoreFactoryTest.php @@ -0,0 +1,193 @@ +getMockLoadBalancerFactory(), + $this->getMockBlobStoreFactory(), + $this->getNameTableStoreFactory(), + $this->getMockSlotRoleRegistry(), + $this->getHashWANObjectCache(), + $this->getMockCommentStore(), + ActorMigration::newMigration(), + MIGRATION_OLD, + $this->getMockLoggerSpi(), + true + ); + $this->assertTrue( true ); + } + + public function provideWikiIds() { + yield [ true ]; + yield [ false ]; + yield [ 'somewiki' ]; + yield [ 'somewiki', MIGRATION_OLD , false ]; + yield [ 'somewiki', MIGRATION_NEW , true ]; + } + + /** + * @dataProvider provideWikiIds + * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStore + */ + public function testGetRevisionStore( + $dbDomain, + $mcrMigrationStage = MIGRATION_OLD, + $contentHandlerUseDb = true + ) { + $lbFactory = $this->getMockLoadBalancerFactory(); + $blobStoreFactory = $this->getMockBlobStoreFactory(); + $nameTableStoreFactory = $this->getNameTableStoreFactory(); + $slotRoleRegistry = $this->getMockSlotRoleRegistry(); + $cache = $this->getHashWANObjectCache(); + $commentStore = $this->getMockCommentStore(); + $actorMigration = ActorMigration::newMigration(); + $loggerProvider = $this->getMockLoggerSpi(); + + $factory = new RevisionStoreFactory( + $lbFactory, + $blobStoreFactory, + $nameTableStoreFactory, + $slotRoleRegistry, + $cache, + $commentStore, + $actorMigration, + $mcrMigrationStage, + $loggerProvider, + $contentHandlerUseDb + ); + + $store = $factory->getRevisionStore( $dbDomain ); + $wrapper = TestingAccessWrapper::newFromObject( $store ); + + // ensure the correct object type is returned + $this->assertInstanceOf( RevisionStore::class, $store ); + + // ensure the RevisionStore is for the given wikiId + $this->assertSame( $dbDomain, $wrapper->dbDomain ); + + // ensure all other required services are correctly set + $this->assertSame( $cache, $wrapper->cache ); + $this->assertSame( $commentStore, $wrapper->commentStore ); + $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage ); + $this->assertSame( $actorMigration, $wrapper->actorMigration ); + $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() ); + + $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer ); + $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore ); + $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer + */ + private function getMockLoadBalancer() { + return $this->getMockBuilder( ILoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory + */ + private function getMockLoadBalancerFactory() { + $mock = $this->getMockBuilder( ILBFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'getMainLB' ) + ->willReturnCallback( function () { + return $this->getMockLoadBalancer(); + } ); + + return $mock; + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore + */ + private function getMockSqlBlobStore() { + return $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory + */ + private function getMockBlobStoreFactory() { + $mock = $this->getMockBuilder( BlobStoreFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'newSqlBlobStore' ) + ->willReturnCallback( function () { + return $this->getMockSqlBlobStore(); + } ); + + return $mock; + } + + /** + * @return SlotRoleRegistry + */ + private function getMockSlotRoleRegistry() { + return $this->createMock( SlotRoleRegistry::class ); + } + + /** + * @return NameTableStoreFactory + */ + private function getNameTableStoreFactory() { + return new NameTableStoreFactory( + $this->getMockLoadBalancerFactory(), + $this->getHashWANObjectCache(), + new NullLogger() ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore + */ + private function getMockCommentStore() { + return $this->getMockBuilder( CommentStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + private function getHashWANObjectCache() { + return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi + */ + private function getMockLoggerSpi() { + $mock = $this->getMock( LoggerSpi::class ); + + $mock->method( 'getLogger' ) + ->willReturn( new NullLogger() ); + + return $mock; + } + +} diff --git a/tests/phpunit/unit/includes/Revision/SlotRoleHandlerTest.php b/tests/phpunit/unit/includes/Revision/SlotRoleHandlerTest.php new file mode 100644 index 0000000000..39217c2597 --- /dev/null +++ b/tests/phpunit/unit/includes/Revision/SlotRoleHandlerTest.php @@ -0,0 +1,64 @@ +createMock( Title::class ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::__construct + * @covers \MediaWiki\Revision\SlotRoleHandler::getRole() + * @covers \MediaWiki\Revision\SlotRoleHandler::getNameMessageKey() + * @covers \MediaWiki\Revision\SlotRoleHandler::getDefaultModel() + * @covers \MediaWiki\Revision\SlotRoleHandler::getOutputLayoutHints() + */ + public function testConstruction() { + $handler = new SlotRoleHandler( 'foo', 'FooModel', [ 'frob' => 'niz' ] ); + $this->assertSame( 'foo', $handler->getRole() ); + $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); + + $title = $this->makeBlankTitleObject(); + $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) ); + + $hints = $handler->getOutputLayoutHints(); + $this->assertArrayHasKey( 'frob', $hints ); + $this->assertSame( 'niz', $hints['frob'] ); + + $this->assertArrayHasKey( 'display', $hints ); + $this->assertArrayHasKey( 'region', $hints ); + $this->assertArrayHasKey( 'placement', $hints ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedModel() { + $handler = new SlotRoleHandler( 'foo', 'FooModel' ); + + $title = $this->makeBlankTitleObject(); + $this->assertTrue( $handler->isAllowedModel( 'FooModel', $title ) ); + $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::supportsArticleCount() + */ + public function testSupportsArticleCount() { + $handler = new SlotRoleHandler( 'foo', 'FooModel' ); + + $this->assertFalse( $handler->supportsArticleCount() ); + } + +} diff --git a/tests/phpunit/unit/includes/ServiceWiringTest.php b/tests/phpunit/unit/includes/ServiceWiringTest.php new file mode 100644 index 0000000000..25b0214db1 --- /dev/null +++ b/tests/phpunit/unit/includes/ServiceWiringTest.php @@ -0,0 +1,16 @@ +assertSame( $sortedServices, $services, + 'Please keep services sorted alphabetically' ); + } +} diff --git a/tests/phpunit/unit/includes/SiteConfigurationTest.php b/tests/phpunit/unit/includes/SiteConfigurationTest.php new file mode 100644 index 0000000000..b992a86471 --- /dev/null +++ b/tests/phpunit/unit/includes/SiteConfigurationTest.php @@ -0,0 +1,379 @@ +mConf = new SiteConfiguration; + + $this->mConf->suffixes = [ 'wikipedia' => 'wiki' ]; + $this->mConf->wikis = [ 'enwiki', 'dewiki', 'frwiki' ]; + $this->mConf->settings = [ + 'SimpleKey' => [ + 'wiki' => 'wiki', + 'tag' => 'tag', + 'enwiki' => 'enwiki', + 'dewiki' => 'dewiki', + 'frwiki' => 'frwiki', + ], + + 'Fallback' => [ + 'default' => 'default', + 'wiki' => 'wiki', + 'tag' => 'tag', + 'frwiki' => 'frwiki', + 'null_wiki' => null, + ], + + 'WithParams' => [ + 'default' => '$lang $site $wiki', + ], + + '+SomeGlobal' => [ + 'wiki' => [ + 'wiki' => 'wiki', + ], + 'tag' => [ + 'tag' => 'tag', + ], + 'enwiki' => [ + 'enwiki' => 'enwiki', + ], + 'dewiki' => [ + 'dewiki' => 'dewiki', + ], + 'frwiki' => [ + 'frwiki' => 'frwiki', + ], + ], + + 'MergeIt' => [ + '+wiki' => [ + 'wiki' => 'wiki', + ], + '+tag' => [ + 'tag' => 'tag', + ], + 'default' => [ + 'default' => 'default', + ], + '+enwiki' => [ + 'enwiki' => 'enwiki', + ], + '+dewiki' => [ + 'dewiki' => 'dewiki', + ], + '+frwiki' => [ + 'frwiki' => 'frwiki', + ], + ], + ]; + + $GLOBALS['SomeGlobal'] = [ 'SomeGlobal' => 'SomeGlobal' ]; + } + + /** + * This function is used as a callback within the tests below + */ + public static function getSiteParamsCallback( $conf, $wiki ) { + $site = null; + $lang = null; + foreach ( $conf->suffixes as $suffix ) { + if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { + $site = $suffix; + $lang = substr( $wiki, 0, -strlen( $suffix ) ); + break; + } + } + + return [ + 'suffix' => $site, + 'lang' => $lang, + 'params' => [ + 'lang' => $lang, + 'site' => $site, + 'wiki' => $wiki, + ], + 'tags' => [ 'tag' ], + ]; + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDb() { + $this->assertEquals( + [ 'wikipedia', 'en' ], + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB()' + ); + $this->assertEquals( + [ 'wikipedia', '' ], + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() on a suffix' + ); + $this->assertEquals( + [ null, null ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki' + ); + + $this->mConf->suffixes = [ 'wiki', '' ]; + $this->assertEquals( + [ '', 'wikien' ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki (2)' + ); + } + + /** + * @covers SiteConfiguration::getLocalDatabases + */ + public function testGetLocalDatabases() { + $this->assertEquals( + [ 'enwiki', 'dewiki', 'frwiki' ], + $this->mConf->getLocalDatabases(), + 'getLocalDatabases()' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testGetConfVariables() { + // Simple + $this->assertEquals( + 'enwiki', + $this->mConf->get( 'SimpleKey', 'enwiki', 'wiki' ), + 'get(): simple setting on an existing wiki' + ); + $this->assertEquals( + 'dewiki', + $this->mConf->get( 'SimpleKey', 'dewiki', 'wiki' ), + 'get(): simple setting on an existing wiki (2)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'SimpleKey', 'frwiki', 'wiki' ), + 'get(): simple setting on an existing wiki (3)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'SimpleKey', 'wiki', 'wiki' ), + 'get(): simple setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ), + 'get(): simple setting on an non-existing wiki' + ); + + // Fallback + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'enwiki', 'wiki' ), + 'get(): fallback setting on an existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'Fallback', 'dewiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an existing wiki (with wiki tag)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'Fallback', 'frwiki', 'wiki', [], [ 'tag' ] ), + 'get(): no fallback if wiki has its own setting (matching tag)' + ); + $this->assertSame( + // Potential regression test for T192855 + null, + $this->mConf->get( 'Fallback', 'null_wiki', 'wiki', [], [ 'tag' ] ), + 'get(): no fallback if wiki has its own setting (matching tag and uses null)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'wiki', 'wiki' ), + 'get(): fallback setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'wiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an suffix (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ), + 'get(): fallback setting on an non-existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an non-existing wiki (with wiki tag)' + ); + + // Merging + $common = [ 'wiki' => 'wiki', 'default' => 'default' ]; + $commonTag = [ 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ]; + $this->assertEquals( + [ 'enwiki' => 'enwiki' ] + $common, + $this->mConf->get( 'MergeIt', 'enwiki', 'wiki' ), + 'get(): merging setting on an existing wiki' + ); + $this->assertEquals( + [ 'enwiki' => 'enwiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'enwiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (with tag)' + ); + $this->assertEquals( + [ 'dewiki' => 'dewiki' ] + $common, + $this->mConf->get( 'MergeIt', 'dewiki', 'wiki' ), + 'get(): merging setting on an existing wiki (2)' + ); + $this->assertEquals( + [ 'dewiki' => 'dewiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'dewiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (2) (with tag)' + ); + $this->assertEquals( + [ 'frwiki' => 'frwiki' ] + $common, + $this->mConf->get( 'MergeIt', 'frwiki', 'wiki' ), + 'get(): merging setting on an existing wiki (3)' + ); + $this->assertEquals( + [ 'frwiki' => 'frwiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'frwiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (3) (with tag)' + ); + $this->assertEquals( + [ 'wiki' => 'wiki' ] + $common, + $this->mConf->get( 'MergeIt', 'wiki', 'wiki' ), + 'get(): merging setting on an suffix' + ); + $this->assertEquals( + [ 'wiki' => 'wiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'wiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an suffix (with tag)' + ); + $this->assertEquals( + $common, + $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ), + 'get(): merging setting on an non-existing wiki' + ); + $this->assertEquals( + $commonTag, + $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an non-existing wiki (with tag)' + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDbWithCallback() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + [ 'wiki', 'en' ], + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB() with callback' + ); + $this->assertEquals( + [ 'wiki', '' ], + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() with callback on a suffix' + ); + $this->assertEquals( + [ null, null ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() with callback on a non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testParameterReplacement() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + 'en wiki enwiki', + $this->mConf->get( 'WithParams', 'enwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki' + ); + $this->assertEquals( + 'de wiki dewiki', + $this->mConf->get( 'WithParams', 'dewiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (2)' + ); + $this->assertEquals( + 'fr wiki frwiki', + $this->mConf->get( 'WithParams', 'frwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (3)' + ); + $this->assertEquals( + ' wiki wiki', + $this->mConf->get( 'WithParams', 'wiki', 'wiki' ), + 'get(): parameter replacement on an suffix' + ); + $this->assertEquals( + 'es wiki eswiki', + $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ), + 'get(): parameter replacement on an non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::getAll + */ + public function testGetAllGlobals() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $getall = [ + 'SimpleKey' => 'enwiki', + 'Fallback' => 'tag', + 'WithParams' => 'en wiki enwiki', + 'SomeGlobal' => [ 'enwiki' => 'enwiki' ] + $GLOBALS['SomeGlobal'], + 'MergeIt' => [ + 'enwiki' => 'enwiki', + 'tag' => 'tag', + 'wiki' => 'wiki', + 'default' => 'default' + ], + ]; + $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); + + $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); + + $this->assertEquals( + $getall['SimpleKey'], + $GLOBALS['SimpleKey'], + 'extractAllGlobals(): simple setting' + ); + $this->assertEquals( + $getall['Fallback'], + $GLOBALS['Fallback'], + 'extractAllGlobals(): fallback setting' + ); + $this->assertEquals( + $getall['WithParams'], + $GLOBALS['WithParams'], + 'extractAllGlobals(): parameter replacement' + ); + $this->assertEquals( + $getall['SomeGlobal'], + $GLOBALS['SomeGlobal'], + 'extractAllGlobals(): merging with global' + ); + $this->assertEquals( + $getall['MergeIt'], + $GLOBALS['MergeIt'], + 'extractAllGlobals(): merging setting' + ); + } +} diff --git a/tests/phpunit/unit/includes/Storage/PreparedEditTest.php b/tests/phpunit/unit/includes/Storage/PreparedEditTest.php new file mode 100644 index 0000000000..e3249e74bc --- /dev/null +++ b/tests/phpunit/unit/includes/Storage/PreparedEditTest.php @@ -0,0 +1,21 @@ +parserOutputCallback = function () { + return new ParserOutput(); + }; + + $this->assertEquals( $output, $edit->getOutput() ); + $this->assertEquals( $output, $edit->output ); + } +} diff --git a/tests/phpunit/unit/includes/XmlSelectTest.php b/tests/phpunit/unit/includes/XmlSelectTest.php new file mode 100644 index 0000000000..54d269e0ce --- /dev/null +++ b/tests/phpunit/unit/includes/XmlSelectTest.php @@ -0,0 +1,182 @@ +select = new XmlSelect(); + } + + protected function tearDown() { + parent::tearDown(); + $this->select = null; + } + + /** + * @covers XmlSelect::__construct + */ + public function testConstructWithoutParameters() { + $this->assertEquals( '', $this->select->getHTML() ); + } + + /** + * Parameters are $name (false), $id (false), $default (false) + * @dataProvider provideConstructionParameters + * @covers XmlSelect::__construct + */ + public function testConstructParameters( $name, $id, $default, $expected ) { + $this->select = new XmlSelect( $name, $id, $default ); + $this->assertEquals( $expected, $this->select->getHTML() ); + } + + /** + * Provide parameters for testConstructParameters() which use three + * parameters: + * - $name (default: false) + * - $id (default: false) + * - $default (default: false) + * Provides a fourth parameters representing the expected HTML output + */ + public static function provideConstructionParameters() { + return [ + /** + * Values are set following a 3-bit Gray code where two successive + * values differ by only one value. + * See https://en.wikipedia.org/wiki/Gray_code + */ + # $name $id $default + [ false, false, false, '' ], + [ false, false, 'foo', '' ], + [ false, 'id', 'foo', '' ], + [ false, 'id', false, '' ], + [ 'name', 'id', false, '' ], + [ 'name', 'id', 'foo', '' ], + [ 'name', false, 'foo', '' ], + [ 'name', false, false, '' ], + ]; + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOption() { + $this->select->addOption( 'foo' ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithDefault() { + $this->select->addOption( 'foo', true ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithFalse() { + $this->select->addOption( 'foo', false ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithValueZero() { + $this->select->addOption( 'foo', 0 ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::setDefault + */ + public function testSetDefault() { + $this->select->setDefault( 'bar1' ); + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * Adding default later on should set the correct selection or + * raise an exception. + * To handle this, we need to render the options in getHtml() + * @covers XmlSelect::setDefault + */ + public function testSetDefaultAfterAddingOptions() { + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->select->setDefault( 'bar1' ); # setting default after adding options + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * @covers XmlSelect::setAttribute + * @covers XmlSelect::getAttribute + */ + public function testGetAttributes() { + # create some attributes + $this->select->setAttribute( 'dummy', 0x777 ); + $this->select->setAttribute( 'string', 'euro €' ); + $this->select->setAttribute( 1911, 'razor' ); + + # verify we can retrieve them + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + $this->assertEquals( + $this->select->getAttribute( 'string' ), + 'euro €' + ); + $this->assertEquals( + $this->select->getAttribute( 1911 ), + 'razor' + ); + + # inexistent keys should give us 'null' + $this->assertEquals( + $this->select->getAttribute( 'I DO NOT EXIT' ), + null + ); + + # verify string / integer + $this->assertEquals( + $this->select->getAttribute( '1911' ), + 'razor' + ); + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + } +} diff --git a/tests/phpunit/unit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/unit/includes/auth/AuthenticationResponseTest.php new file mode 100644 index 0000000000..44b063153b --- /dev/null +++ b/tests/phpunit/unit/includes/auth/AuthenticationResponseTest.php @@ -0,0 +1,112 @@ +messageType = 'warning'; + foreach ( $expect as $field => $value ) { + $res->$field = $value; + } + $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->assertEquals( $res, $ret ); + } else { + try { + call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public function provideConstructors() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $msg = new \Message( 'mainpage' ); + + return [ + [ 'newPass', [], [ + 'status' => AuthenticationResponse::PASS, + ] ], + [ 'newPass', [ 'name' ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + [ 'newPass', [ 'name', null ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + + [ 'newFail', [ $msg ], [ + 'status' => AuthenticationResponse::FAIL, + 'message' => $msg, + 'messageType' => 'error', + ] ], + + [ 'newRestart', [ $msg ], [ + 'status' => AuthenticationResponse::RESTART, + 'message' => $msg, + ] ], + + [ 'newAbstain', [], [ + 'status' => AuthenticationResponse::ABSTAIN, + ] ], + + [ 'newUI', [ [ $req ], $msg ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'warning' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'error' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'error', + ] ], + [ 'newUI', [ [], $msg ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + + [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + ] ], + [ + 'newRedirect', + [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], + [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + 'redirectApiData' => [ 'foo' => 'bar' ], + ] + ], + [ 'newRedirect', [ [], 'http://example.org/redir' ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + ]; + } + +} diff --git a/tests/phpunit/unit/includes/changes/ChangesListFilterGroupTest.php b/tests/phpunit/unit/includes/changes/ChangesListFilterGroupTest.php new file mode 100644 index 0000000000..bd54d508ba --- /dev/null +++ b/tests/phpunit/unit/includes/changes/ChangesListFilterGroupTest.php @@ -0,0 +1,79 @@ + 'some_type', + 'name' => 'group_name', + 'priority' => 1, + 'filters' => [], + ] + ); + } + + public function testAutoPriorities() { + $group = new MockChangesListFilterGroup( + [ + 'type' => 'some_type', + 'name' => 'groupName', + 'isFullCoverage' => true, + 'priority' => 1, + 'filters' => [ + [ 'name' => 'hidefoo' ], + [ 'name' => 'hidebar' ], + [ 'name' => 'hidebaz' ], + ], + ] + ); + + $filters = $group->getFilters(); + $this->assertEquals( + [ + -2, + -3, + -4, + ], + array_map( + function ( $f ) { + return $f->getPriority(); + }, + array_values( $filters ) + ) + ); + } + + // Get without warnings + public function testGetFilter() { + $group = new MockChangesListFilterGroup( + [ + 'type' => 'some_type', + 'name' => 'groupName', + 'isFullCoverage' => true, + 'priority' => 1, + 'filters' => [ + [ 'name' => 'foo' ], + ], + ] + ); + + $this->assertEquals( + 'foo', + $group->getFilter( 'foo' )->getName() + ); + + $this->assertEquals( + null, + $group->getFilter( 'bar' ) + ); + } +} diff --git a/tests/phpunit/unit/includes/config/ConfigFactoryTest.php b/tests/phpunit/unit/includes/config/ConfigFactoryTest.php new file mode 100644 index 0000000000..a136018c96 --- /dev/null +++ b/tests/phpunit/unit/includes/config/ConfigFactoryTest.php @@ -0,0 +1,168 @@ +register( 'unittest', 'GlobalVarConfig::newInstance' ); + $this->assertInstanceOf( GlobalVarConfig::class, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInvalid() { + $factory = new ConfigFactory(); + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalid', 'Invalid callback' ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInvalidInstance() { + $factory = new ConfigFactory(); + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalidInstance', new stdClass ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInstance() { + $config = GlobalVarConfig::newInstance(); + $factory = new ConfigFactory(); + $factory->register( 'unittest', $config ); + $this->assertSame( $config, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterAgain() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $config1 = $factory->makeConfig( 'unittest' ); + + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $config2 = $factory->makeConfig( 'unittest' ); + + $this->assertNotSame( $config1, $config2 ); + } + + /** + * @covers ConfigFactory::salvage + */ + public function testSalvage() { + $oldFactory = new ConfigFactory(); + $oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' ); + + // instantiate two of the three defined configurations + $foo = $oldFactory->makeConfig( 'foo' ); + $bar = $oldFactory->makeConfig( 'bar' ); + $quux = $oldFactory->makeConfig( 'quux' ); + + // define new config instance + $newFactory = new ConfigFactory(); + $newFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $newFactory->register( 'bar', function () { + return new HashConfig(); + } ); + + // "foo" and "quux" are defined in the old and the new factory. + // The old factory has instances for "foo" and "bar", but not "quux". + $newFactory->salvage( $oldFactory ); + + $newFoo = $newFactory->makeConfig( 'foo' ); + $this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' ); + + $newBar = $newFactory->makeConfig( 'bar' ); + $this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' ); + + // the new factory doesn't have quux defined, so the quux instance should not be salvaged + $this->setExpectedException( ConfigException::class ); + $newFactory->makeConfig( 'quux' ); + } + + /** + * @covers ConfigFactory::getConfigNames + */ + public function testGetConfigNames() { + $factory = new ConfigFactory(); + $factory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $factory->register( 'bar', new HashConfig() ); + + $this->assertEquals( [ 'foo', 'bar' ], $factory->getConfigNames() ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( Config::class, $conf ); + $this->assertSame( $conf, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithObject() { + $factory = new ConfigFactory(); + $conf = new HashConfig(); + $factory->register( 'test', $conf ); + $this->assertSame( $conf, $factory->makeConfig( 'test' ) ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigFallback() { + $factory = new ConfigFactory(); + $factory->register( '*', 'GlobalVarConfig::newInstance' ); + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( Config::class, $conf ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithNoBuilders() { + $factory = new ConfigFactory(); + $this->setExpectedException( ConfigException::class ); + $factory->makeConfig( 'nobuilderregistered' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithInvalidCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', function () { + return true; // Not a Config object + } ); + $this->setExpectedException( UnexpectedValueException::class ); + $factory->makeConfig( 'unittest' ); + } + + /** + * @covers ConfigFactory::getDefaultInstance + */ + public function testGetDefaultInstance() { + // NOTE: the global config factory returned here has been overwritten + // for operation in test mode. It may not reflect LocalSettings. + $factory = MediaWikiServices::getInstance()->getConfigFactory(); + $this->assertInstanceOf( Config::class, $factory->makeConfig( 'main' ) ); + } + +} diff --git a/tests/phpunit/unit/includes/config/HashConfigTest.php b/tests/phpunit/unit/includes/config/HashConfigTest.php new file mode 100644 index 0000000000..d46ee09d5b --- /dev/null +++ b/tests/phpunit/unit/includes/config/HashConfigTest.php @@ -0,0 +1,63 @@ +assertInstanceOf( HashConfig::class, $conf ); + } + + /** + * @covers HashConfig::__construct + */ + public function testConstructor() { + $conf = new HashConfig(); + $this->assertInstanceOf( HashConfig::class, $conf ); + + // Test passing arguments to the constructor + $conf2 = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertEquals( '1', $conf2->get( 'one' ) ); + } + + /** + * @covers HashConfig::get + */ + public function testGet() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertEquals( '1', $conf->get( 'one' ) ); + $this->setExpectedException( ConfigException::class, 'HashConfig::get: undefined option' ); + $conf->get( 'two' ); + } + + /** + * @covers HashConfig::has + */ + public function testHas() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertTrue( $conf->has( 'one' ) ); + $this->assertFalse( $conf->has( 'two' ) ); + } + + /** + * @covers HashConfig::set + */ + public function testSet() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $conf->set( 'two', '2' ); + $this->assertEquals( '2', $conf->get( 'two' ) ); + // Check that set overwrites + $conf->set( 'one', '3' ); + $this->assertEquals( '3', $conf->get( 'one' ) ); + } +} diff --git a/tests/phpunit/unit/includes/config/MultiConfigTest.php b/tests/phpunit/unit/includes/config/MultiConfigTest.php new file mode 100644 index 0000000000..4351151302 --- /dev/null +++ b/tests/phpunit/unit/includes/config/MultiConfigTest.php @@ -0,0 +1,39 @@ + 'bar' ] ), + new HashConfig( [ 'foo' => 'baz', 'bar' => 'foo' ] ), + new HashConfig( [ 'bar' => 'baz' ] ), + ] ); + + $this->assertEquals( 'bar', $multi->get( 'foo' ) ); + $this->assertEquals( 'foo', $multi->get( 'bar' ) ); + $this->setExpectedException( ConfigException::class, 'MultiConfig::get: undefined option:' ); + $multi->get( 'notset' ); + } + + /** + * @covers MultiConfig::has + */ + public function testHas() { + $conf = new MultiConfig( [ + new HashConfig( [ 'foo' => 'foo' ] ), + new HashConfig( [ 'something' => 'bleh' ] ), + new HashConfig( [ 'meh' => 'eh' ] ), + ] ); + + $this->assertTrue( $conf->has( 'foo' ) ); + $this->assertTrue( $conf->has( 'something' ) ); + $this->assertTrue( $conf->has( 'meh' ) ); + $this->assertFalse( $conf->has( 'what' ) ); + } +} diff --git a/tests/phpunit/unit/includes/config/ServiceOptionsTest.php b/tests/phpunit/unit/includes/config/ServiceOptionsTest.php new file mode 100644 index 0000000000..c58c6f579c --- /dev/null +++ b/tests/phpunit/unit/includes/config/ServiceOptionsTest.php @@ -0,0 +1,149 @@ + $val ) { + $this->assertSame( $val, $options->get( $key ) ); + } + + // This is lumped in the same test because there's no support for depending on a test that + // has a data provider. + $options->assertRequiredOptions( array_keys( $expected ) ); + + // Suppress warning if no assertions were run. This is expected for empty arguments. + $this->assertTrue( true ); + } + + public function provideConstructor() { + return [ + 'No keys' => [ [], [], [ 'a' => 'aval' ] ], + 'Simple array source' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ], + ], + 'Simple HashConfig source' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + new HashConfig( [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ] ), + ], + 'Three different sources' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + [ 'z' => 'zval' ], + new HashConfig( [ 'a' => 'aval', 'c' => 'cval' ] ), + [ 'b' => 'bval', 'd' => 'dval' ], + ], + 'null key' => [ + [ 'a' => null ], + [ 'a' ], + [ 'a' => null ], + ], + 'Numeric option name' => [ + [ '0' => 'nothing' ], + [ '0' ], + [ '0' => 'nothing' ], + ], + 'Multiple sources for one key' => [ + [ 'a' => 'winner' ], + [ 'a' ], + [ 'a' => 'winner' ], + [ 'a' => 'second place' ], + ], + 'Object value is passed by reference' => [ + [ 'a' => self::$testObj ], + [ 'a' ], + [ 'a' => self::$testObj ], + ], + ]; + } + + /** + * @covers ::__construct + */ + public function testKeyNotFound() { + $this->setExpectedException( InvalidArgumentException::class, + 'Key "a" not found in input sources' ); + + new ServiceOptions( [ 'a' ], [ 'b' => 'bval' ], [ 'c' => 'cval' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testOutOfOrderAssertRequiredOptions() { + $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); + $options->assertRequiredOptions( [ 'b', 'a' ] ); + $this->assertTrue( true, 'No exception thrown' ); + } + + /** + * @covers ::__construct + * @covers ::get + */ + public function testGetUnrecognized() { + $this->setExpectedException( InvalidArgumentException::class, + 'Unrecognized option "b"' ); + + $options = new ServiceOptions( [ 'a' ], [ 'a' => '' ] ); + $options->get( 'b' ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testExtraKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Unsupported options passed: b, c!' ); + + $options = new ServiceOptions( [ 'a', 'b', 'c' ], [ 'a' => '', 'b' => '', 'c' => '' ] ); + $options->assertRequiredOptions( [ 'a' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testMissingKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Required options missing: a, b!' ); + + $options = new ServiceOptions( [ 'c' ], [ 'c' => '' ] ); + $options->assertRequiredOptions( [ 'a', 'b', 'c' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testExtraAndMissingKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Unsupported options passed: b! Required options missing: c!' ); + + $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); + $options->assertRequiredOptions( [ 'a', 'c' ] ); + } +} diff --git a/tests/phpunit/unit/includes/content/JsonContentHandlerTest.php b/tests/phpunit/unit/includes/content/JsonContentHandlerTest.php new file mode 100644 index 0000000000..70db73c810 --- /dev/null +++ b/tests/phpunit/unit/includes/content/JsonContentHandlerTest.php @@ -0,0 +1,14 @@ +makeEmptyContent(); + $this->assertInstanceOf( JsonContent::class, $content ); + $this->assertTrue( $content->isValid() ); + } +} diff --git a/tests/phpunit/unit/includes/debug/logger/MonologSpiTest.php b/tests/phpunit/unit/includes/debug/logger/MonologSpiTest.php new file mode 100644 index 0000000000..ecb5d17c2a --- /dev/null +++ b/tests/phpunit/unit/includes/debug/logger/MonologSpiTest.php @@ -0,0 +1,135 @@ + [ + '@default' => [ + 'processors' => [ 'constructor' ], + 'handlers' => [ 'constructor' ], + ], + ], + 'processors' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + ], + 'handlers' => [ + 'constructor' => [ + 'class' => 'constructor', + 'formatter' => 'constructor', + ], + ], + 'formatters' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + ], + ]; + + $fixture = new MonologSpi( $base ); + $this->assertSame( + $base, + TestingAccessWrapper::newFromObject( $fixture )->config + ); + + $fixture->mergeConfig( [ + 'loggers' => [ + 'merged' => [ + 'processors' => [ 'merged' ], + 'handlers' => [ 'merged' ], + ], + ], + 'processors' => [ + 'merged' => [ + 'class' => 'merged', + ], + ], + 'magic' => [ + 'idkfa' => [ 'xyzzy' ], + ], + 'handlers' => [ + 'merged' => [ + 'class' => 'merged', + 'formatter' => 'merged', + ], + ], + 'formatters' => [ + 'merged' => [ + 'class' => 'merged', + ], + ], + ] ); + $this->assertSame( + [ + 'loggers' => [ + '@default' => [ + 'processors' => [ 'constructor' ], + 'handlers' => [ 'constructor' ], + ], + 'merged' => [ + 'processors' => [ 'merged' ], + 'handlers' => [ 'merged' ], + ], + ], + 'processors' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + ], + ], + 'handlers' => [ + 'constructor' => [ + 'class' => 'constructor', + 'formatter' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + 'formatter' => 'merged', + ], + ], + 'formatters' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + ], + ], + 'magic' => [ + 'idkfa' => [ 'xyzzy' ], + ], + ], + TestingAccessWrapper::newFromObject( $fixture )->config + ); + } + +} diff --git a/tests/phpunit/unit/includes/debug/logger/monolog/AvroFormatterTest.php b/tests/phpunit/unit/includes/debug/logger/monolog/AvroFormatterTest.php new file mode 100644 index 0000000000..e091561923 --- /dev/null +++ b/tests/phpunit/unit/includes/debug/logger/monolog/AvroFormatterTest.php @@ -0,0 +1,75 @@ +markTestSkipped( 'Avro is required for the AvroFormatterTest' ); + } + parent::setUp(); + } + + public function testSchemaNotAvailable() { + $formatter = new AvroFormatter( [] ); + $this->setExpectedException( + 'PHPUnit_Framework_Error_Notice', + "The schema for channel 'marty' is not available" + ); + $formatter->format( [ 'channel' => 'marty' ] ); + } + + public function testSchemaNotAvailableReturnValue() { + $formatter = new AvroFormatter( [] ); + $noticeEnabled = PHPUnit_Framework_Error_Notice::$enabled; + // disable conversion of notices + PHPUnit_Framework_Error_Notice::$enabled = false; + // have to keep the user notice from being output + \Wikimedia\suppressWarnings(); + $res = $formatter->format( [ 'channel' => 'marty' ] ); + \Wikimedia\restoreWarnings(); + PHPUnit_Framework_Error_Notice::$enabled = $noticeEnabled; + $this->assertNull( $res ); + } + + public function testDoesSomethingWhenSchemaAvailable() { + $formatter = new AvroFormatter( [ + 'string' => [ + 'schema' => [ 'type' => 'string' ], + 'revision' => 1010101, + ] + ] ); + $res = $formatter->format( [ + 'channel' => 'string', + 'context' => 'better to be', + ] ); + $this->assertNotNull( $res ); + // basically just tell us if avro changes its string encoding, or if + // we completely fail to generate a log message. + $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) ); + } +} diff --git a/tests/phpunit/unit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/unit/includes/debug/logger/monolog/KafkaHandlerTest.php new file mode 100644 index 0000000000..bbac17f1cc --- /dev/null +++ b/tests/phpunit/unit/includes/debug/logger/monolog/KafkaHandlerTest.php @@ -0,0 +1,226 @@ +markTestSkipped( 'Monolog and Kafka are required for the KafkaHandlerTest' ); + } + + parent::setUp(); + } + + public function topicNamingProvider() { + return [ + [ [], 'monolog_foo' ], + [ [ 'alias' => [ 'foo' => 'bar' ] ], 'bar' ] + ]; + } + + /** + * @dataProvider topicNamingProvider + */ + public function testTopicNaming( $options, $expect ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->once() ) + ->method( 'setMessages' ) + ->with( $expect, $this->anything(), $this->anything() ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + } + + public function swallowsExceptionsWhenRequested() { + return [ + // defaults to false + [ [], true ], + // also try false explicitly + [ [ 'swallowExceptions' => false ], true ], + // turn it on + [ [ 'swallowExceptions' => true ], false ], + ]; + } + + /** + * @dataProvider swallowsExceptionsWhenRequested + */ + public function testGetAvailablePartitionsException( $options, $expectException ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->throwException( new \Kafka\Exception ) ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + if ( $expectException ) { + $this->setExpectedException( 'Kafka\Exception' ); + } + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + + if ( !$expectException ) { + $this->assertTrue( true, 'no exception was thrown' ); + } + } + + /** + * @dataProvider swallowsExceptionsWhenRequested + */ + public function testSendException( $options, $expectException ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->throwException( new \Kafka\Exception ) ); + + if ( $expectException ) { + $this->setExpectedException( 'Kafka\Exception' ); + } + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + + if ( !$expectException ) { + $this->assertTrue( true, 'no exception was thrown' ); + } + } + + public function testHandlesNullFormatterResult() { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $mockMethod = $produce->expects( $this->exactly( 2 ) ) + ->method( 'setMessages' ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + // evil hax + $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher; + TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher = + new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ + [ $this->anything(), $this->anything(), [ 'words' ] ], + [ $this->anything(), $this->anything(), [ 'lines' ] ] + ] ); + + $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); + $formatter->expects( $this->any() ) + ->method( 'format' ) + ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); + + $handler = new KafkaHandler( $produce, [] ); + $handler->setFormatter( $formatter ); + for ( $i = 0; $i < 3; ++$i ) { + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + } + } + + public function testBatchHandlesNullFormatterResult() { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->once() ) + ->method( 'setMessages' ) + ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); + $formatter->expects( $this->any() ) + ->method( 'format' ) + ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); + + $handler = new KafkaHandler( $produce, [] ); + $handler->setFormatter( $formatter ); + $handler->handleBatch( [ + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + ] ); + } +} diff --git a/tests/phpunit/unit/includes/debug/logger/monolog/LineFormatterTest.php b/tests/phpunit/unit/includes/debug/logger/monolog/LineFormatterTest.php new file mode 100644 index 0000000000..8da3d9304e --- /dev/null +++ b/tests/phpunit/unit/includes/debug/logger/monolog/LineFormatterTest.php @@ -0,0 +1,121 @@ +markTestSkipped( 'This test requires monolog to be installed' ); + } + parent::setUp(); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionNoTrace() { + $fixture = new LineFormatter(); + $fixture->includeStacktraces( false ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new LogicException( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); + $this->assertNotContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionTrace() { + $fixture = new LineFormatter(); + $fixture->includeStacktraces( true ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new LogicException( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); + $this->assertContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionErrorNoTrace() { + if ( !class_exists( AssertionError::class ) ) { + $this->markTestSkipped( 'AssertionError class does not exist' ); + } + + $fixture = new LineFormatter(); + $fixture->includeStacktraces( false ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new AssertionError( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); + $this->assertNotContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionErrorTrace() { + if ( !class_exists( AssertionError::class ) ) { + $this->markTestSkipped( 'AssertionError class does not exist' ); + } + + $fixture = new LineFormatter(); + $fixture->includeStacktraces( true ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new AssertionError( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); + $this->assertContains( "\n #0", $out ); + } +} diff --git a/tests/phpunit/unit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/unit/includes/diff/ArrayDiffFormatterTest.php new file mode 100644 index 0000000000..d436991fe9 --- /dev/null +++ b/tests/phpunit/unit/includes/diff/ArrayDiffFormatterTest.php @@ -0,0 +1,134 @@ +format( $input ); + $this->assertEquals( $expectedOutput, $output ); + } + + private function getMockDiff( $edits ) { + $diff = $this->getMockBuilder( Diff::class ) + ->disableOriginalConstructor() + ->getMock(); + $diff->expects( $this->any() ) + ->method( 'getEdits' ) + ->will( $this->returnValue( $edits ) ); + return $diff; + } + + private function getMockDiffOp( $type = null, $orig = [], $closing = [] ) { + $diffOp = $this->getMockBuilder( DiffOp::class ) + ->disableOriginalConstructor() + ->getMock(); + $diffOp->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $type ) ); + $diffOp->expects( $this->any() ) + ->method( 'getOrig' ) + ->will( $this->returnValue( $orig ) ); + if ( $type === 'change' ) { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->with( $this->isType( 'integer' ) ) + ->will( $this->returnCallback( function () { + return 'mockLine'; + } ) ); + } else { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->will( $this->returnValue( $closing ) ); + } + return $diffOp; + } + + public function provideTestFormat() { + $emptyArrayTestCases = [ + $this->getMockDiff( [] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'add' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'delete' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'change' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'copy' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'FOOBARBAZ' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'add', 'line' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [], [ 'line' ] ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'copy', [], [ 'line' ] ) ] ), + ]; + + $otherTestCases = []; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1' ] ) ] ), + [ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1', 'a2' ] ) ] ), + [ + [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ], + [ 'action' => 'add', 'new' => 'a2', 'newline' => 2 ], + ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1' ] ) ] ), + [ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1', 'd2' ] ) ] ), + [ + [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ], + [ 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ], + ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'change', [ 'd1' ], [ 'a1' ] ) ] ), + [ [ + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( + 'change', + [ 'd1', 'd2' ], + [ 'a1', 'a2' ] + ) ] ), + [ + [ + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ], + [ + 'action' => 'change', + 'old' => 'd2', + 'new' => 'mockLine', + 'newline' => 2, 'oldline' => 2 + ], + ], + ]; + + $testCases = []; + foreach ( $emptyArrayTestCases as $testCase ) { + $testCases[] = [ $testCase, [] ]; + } + foreach ( $otherTestCases as $testCase ) { + $testCases[] = [ $testCase[0], $testCase[1] ]; + } + return $testCases; + } + +} diff --git a/tests/phpunit/unit/includes/diff/DiffOpTest.php b/tests/phpunit/unit/includes/diff/DiffOpTest.php new file mode 100644 index 0000000000..4e1aced7a6 --- /dev/null +++ b/tests/phpunit/unit/includes/diff/DiffOpTest.php @@ -0,0 +1,68 @@ +type = 'foo'; + $this->assertEquals( 'foo', $obj->getType() ); + } + + /** + * @covers DiffOp::getOrig + */ + public function testGetOrig() { + $obj = new FakeDiffOp(); + $obj->orig = [ 'foo' ]; + $this->assertEquals( [ 'foo' ], $obj->getOrig() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosing() { + $obj = new FakeDiffOp(); + $obj->closing = [ 'foo' ]; + $this->assertEquals( [ 'foo' ], $obj->getClosing() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosingWithParameter() { + $obj = new FakeDiffOp(); + $obj->closing = [ 'foo', 'bar', 'baz' ]; + $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); + $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); + $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); + $this->assertEquals( null, $obj->getClosing( 3 ) ); + } + + /** + * @covers DiffOp::norig + */ + public function testNorig() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->norig() ); + $obj->orig = [ 'foo' ]; + $this->assertEquals( 1, $obj->norig() ); + } + + /** + * @covers DiffOp::nclosing + */ + public function testNclosing() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->nclosing() ); + $obj->closing = [ 'foo' ]; + $this->assertEquals( 1, $obj->nclosing() ); + } + +} diff --git a/tests/phpunit/unit/includes/diff/DiffTest.php b/tests/phpunit/unit/includes/diff/DiffTest.php new file mode 100644 index 0000000000..f0a8490f72 --- /dev/null +++ b/tests/phpunit/unit/includes/diff/DiffTest.php @@ -0,0 +1,19 @@ +edits = 'FooBarBaz'; + $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); + } + +} diff --git a/tests/phpunit/unit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/unit/includes/exception/MWExceptionHandlerTest.php new file mode 100644 index 0000000000..2b021c4f77 --- /dev/null +++ b/tests/phpunit/unit/includes/exception/MWExceptionHandlerTest.php @@ -0,0 +1,74 @@ +getTrace(); + $hasObject = false; + $hasArray = false; + foreach ( $trace as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $hasObject = $hasObject || is_object( $arg ); + $hasArray = $hasArray || is_array( $arg ); + } + + if ( $hasObject && $hasArray ) { + break; + } + } + $this->assertTrue( $hasObject, + "The stacktrace must have a function having an object has parameter" ); + $this->assertTrue( $hasArray, + "The stacktrace must have a function having an array has parameter" ); + + # Now we redact the trace.. and make sure no function arguments are + # arrays or objects. + $redacted = MWExceptionHandler::getRedactedTrace( $e ); + + foreach ( $redacted as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $this->assertNotInternalType( 'array', $arg ); + $this->assertNotInternalType( 'object', $arg ); + } + } + + $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); + } + + /** + * Helper function for testExpandArgumentsInCall + * + * Pass it an object and an array, and something by reference :-) + * + * @throws Exception + */ + protected static function helperThrowAnException( $a, $b, &$c ) { + throw new Exception(); + } +} diff --git a/tests/phpunit/unit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/unit/includes/installer/InstallDocFormatterTest.php new file mode 100644 index 0000000000..fddc3b8680 --- /dev/null +++ b/tests/phpunit/unit/includes/installer/InstallDocFormatterTest.php @@ -0,0 +1,83 @@ +assertEquals( + $expected, + InstallDocFormatter::format( $unformattedText ), + $message + ); + } + + /** + * Provider for testFormat() + */ + public static function provideDocFormattingTests() { + # Format: (expected string, unformattedText string, optional message) + return [ + # Escape some wikitext + [ 'Install <tag>', 'Install ', 'Escaping <' ], + [ 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ], + [ 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ], + [ 'Install __TOC__', 'Install __TOC__', 'Escaping __' ], + [ 'Install ', "Install \r", 'Removing \r' ], + + # Transform \t{1,2} into :{1,2} + [ ':One indentation', "\tOne indentation", 'Replacing a single \t' ], + [ '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ], + + # Transform 'T123' links + [ + '[https://phabricator.wikimedia.org/T123 T123]', + 'T123', 'Testing T123 links' ], + [ + 'bug [https://phabricator.wikimedia.org/T123 T123]', + 'bug T123', 'Testing bug T123 links' ], + [ + '([https://phabricator.wikimedia.org/T987654 T987654])', + '(T987654)', 'Testing (T987654) links' ], + + # "Tabc" shouldn't work + [ 'Tfoobar', 'Tfoobar', "Don't match T followed by non-digits" ], + [ 'T!!fakefake!!', 'T!!fakefake!!', "Don't match T followed by non-digits" ], + + # Transform 'bug 123' links + [ + '[https://bugzilla.wikimedia.org/123 bug 123]', + 'bug 123', 'Testing bug 123 links' ], + [ + '([https://bugzilla.wikimedia.org/987654 bug 987654])', + '(bug 987654)', 'Testing (bug 987654) links' ], + + # "bug abc" shouldn't work + [ 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ], + [ 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ], + + # Transform '$wgFooBar' links + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]', + '$wgFooBar', 'Testing basic $wgFooBar' ], + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]', + '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ], + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]', + '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ], + + # Icky variables that shouldn't link + [ + '$myAwesomeVariable', + '$myAwesomeVariable', + 'Testing $myAwesomeVariable (not starting with $wg)' + ], + [ '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ], + ]; + } +} diff --git a/tests/phpunit/unit/includes/installer/OracleInstallerTest.php b/tests/phpunit/unit/includes/installer/OracleInstallerTest.php new file mode 100644 index 0000000000..69b5552a56 --- /dev/null +++ b/tests/phpunit/unit/includes/installer/OracleInstallerTest.php @@ -0,0 +1,48 @@ +assertEquals( $expected, + OracleInstaller::checkConnectStringFormat( $connectString ), + $msg + ); + } + + /** + * Provider to test OracleInstaller::checkConnectStringFormat() + */ + function provideOracleConnectStrings() { + // expected result, connectString[, message] + return [ + [ true, 'simple_01', 'Simple TNS name' ], + [ true, 'simple_01.world', 'TNS name with domain' ], + [ true, 'simple_01.domain.net', 'TNS name with domain' ], + [ true, 'host123', 'Host only' ], + [ true, 'host123.domain.net', 'FQDN only' ], + [ true, '//host123.domain.net', 'FQDN URL only' ], + [ true, '123.223.213.132', 'Host IP only' ], + [ true, 'host:1521', 'Host and port' ], + [ true, 'host:1521/service', 'Host, port and service' ], + [ true, 'host:1521/service:shared', 'Host, port, service and shared server type' ], + [ true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ], + [ true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ], + [ + true, + 'host:1521/service:shared/instance1', + 'Host, port, service, server type and instance' + ], + [ true, 'host:1521//instance1', 'Host, port and instance' ], + ]; + } + +} diff --git a/tests/phpunit/unit/includes/interwiki/InterwikiLookupAdapterTest.php b/tests/phpunit/unit/includes/interwiki/InterwikiLookupAdapterTest.php new file mode 100644 index 0000000000..abbd2d778e --- /dev/null +++ b/tests/phpunit/unit/includes/interwiki/InterwikiLookupAdapterTest.php @@ -0,0 +1,133 @@ +interwikiLookup = new InterwikiLookupAdapter( + $this->getSiteLookup( $this->getSites() ) + ); + } + + public function testIsValidInterwiki() { + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'enwt' ), + 'enwt known prefix is valid' + ); + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'foo' ), + 'foo site known prefix is valid' + ); + $this->assertFalse( + $this->interwikiLookup->isValidInterwiki( 'xyz' ), + 'unknown prefix is not valid' + ); + } + + public function testFetch() { + $interwiki = $this->interwikiLookup->fetch( '' ); + $this->assertNull( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'xyz' ); + $this->assertFalse( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'foo' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + $this->assertSame( 'foobar', $interwiki->getWikiID() ); + + $interwiki = $this->interwikiLookup->fetch( 'enwt' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + + $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' ); + $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); + $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' ); + $this->assertTrue( $interwiki->isLocal(), 'isLocal' ); + } + + public function testGetAllPrefixes() { + $foo = [ + 'iw_prefix' => 'foo', + 'iw_url' => '', + 'iw_api' => '', + 'iw_wikiid' => 'foobar', + 'iw_local' => false, + 'iw_trans' => false, + ]; + $enwt = [ + 'iw_prefix' => 'enwt', + 'iw_url' => 'https://en.wiktionary.org/wiki/$1', + 'iw_api' => 'https://en.wiktionary.org/w/api.php', + 'iw_wikiid' => 'enwiktionary', + 'iw_local' => true, + 'iw_trans' => false, + ]; + + $this->assertEquals( + [ $foo, $enwt ], + $this->interwikiLookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + + $this->assertEquals( + [ $foo ], + $this->interwikiLookup->getAllPrefixes( false ), + 'get external prefixes' + ); + + $this->assertEquals( + [ $enwt ], + $this->interwikiLookup->getAllPrefixes( true ), + 'get local prefixes' + ); + } + + private function getSiteLookup( SiteList $sites ) { + $siteLookup = $this->getMockBuilder( SiteLookup::class ) + ->disableOriginalConstructor() + ->getMock(); + + $siteLookup->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnValue( $sites ) ); + + return $siteLookup; + } + + private function getSites() { + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'foobar' ); + $site->addInterwikiId( 'foo' ); + $site->setSource( 'external' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'enwiktionary' ); + $site->addInterwikiId( 'enwt' ); + $site->setSource( 'local' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); + $sites[] = $site; + + return new SiteList( $sites ); + } + +} diff --git a/tests/phpunit/unit/includes/json/FormatJsonUnitTest.php b/tests/phpunit/unit/includes/json/FormatJsonUnitTest.php new file mode 100644 index 0000000000..58ad495518 --- /dev/null +++ b/tests/phpunit/unit/includes/json/FormatJsonUnitTest.php @@ -0,0 +1,362 @@ + new stdClass, + 'emptyArray' => [], + 'string' => 'foobar\\', + 'filledArray' => [ + [ + 123, + 456, + ], + // Nested json works without problems + '"7":["8",{"9":"10"}]', + // Whitespace clean up doesn't touch strings that look alike + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", + ], + ]; + + // No trailing whitespace, no trailing linefeed + $json = '{ + "emptyObject": {}, + "emptyArray": [], + "string": "foobar\\\\", + "filledArray": [ + [ + 123, + 456 + ], + "\"7\":[\"8\",{\"9\":\"10\"}]", + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" + ] +}'; + + $json = str_replace( "\r", '', $json ); // Windows compat + $json = str_replace( "\t", $expectedIndent, $json ); + $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); + } + + public static function provideEncodeDefault() { + return self::getEncodeTestCases( [] ); + } + + /** + * @dataProvider provideEncodeDefault + */ + public function testEncodeDefault( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from ) ); + } + + public static function provideEncodeUtf8() { + return self::getEncodeTestCases( [ 'unicode' ] ); + } + + /** + * @dataProvider provideEncodeUtf8 + */ + public function testEncodeUtf8( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); + } + + public static function provideEncodeXmlMeta() { + return self::getEncodeTestCases( [ 'xmlmeta' ] ); + } + + /** + * @dataProvider provideEncodeXmlMeta + */ + public function testEncodeXmlMeta( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); + } + + public static function provideEncodeAllOk() { + return self::getEncodeTestCases( [ 'unicode', 'xmlmeta' ] ); + } + + /** + * @dataProvider provideEncodeAllOk + */ + public function testEncodeAllOk( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); + } + + public function testEncodePhpBug46944() { + $this->assertNotEquals( + '\ud840\udc00', + strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), + 'Test encoding an broken json_encode character (U+20000)' + ); + } + + public function testEncodeFail() { + // Set up a recursive object that can't be encoded. + $a = new stdClass; + $b = new stdClass; + $a->b = $b; + $b->a = $a; + $this->assertFalse( FormatJson::encode( $a ) ); + } + + public function testDecodeReturnType() { + $this->assertInternalType( + 'object', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), + 'Default to object' + ); + + $this->assertInternalType( + 'array', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), + 'Optional array' + ); + } + + public static function provideParse() { + return [ + [ null ], + [ true ], + [ false ], + [ 0 ], + [ 1 ], + [ 1.2 ], + [ '' ], + [ 'str' ], + [ [ 0, 1, 2 ] ], + [ [ 'a' => 'b' ] ], + [ [ 'a' => 'b' ] ], + [ [ 'a' => 'b', 'x' => [ 'c' => 'd' ] ] ], + ]; + } + + /** + * Recursively convert arrays into stdClass + * @param array|string|bool|int|float|null $value + * @return stdClass|string|bool|int|float|null + */ + public static function toObject( $value ) { + return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value ); + } + + /** + * @dataProvider provideParse + * @param mixed $value + */ + public function testParse( $value ) { + $expected = self::toObject( $value ); + $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); + $this->assertJson( $json ); + + $st = FormatJson::parse( $json ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expected, $st->getValue() ); + + $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $value, $st->getValue() ); + } + + public static function provideParseErrors() { + return [ + [ 'aaa' ], + [ '{"j": 1 ] }' ], + ]; + } + + /** + * @dataProvider provideParseErrors + * @param mixed $value + */ + public function testParseErrors( $value ) { + $st = FormatJson::parse( $value ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertFalse( $st->isOK() ); + } + + public function provideStripComments() { + return [ + [ '{"a":"b"}', '{"a":"b"}' ], + [ "{\"a\":\"b\"}\n", "{\"a\":\"b\"}\n" ], + [ '/*c*/{"c":"b"}', '{"c":"b"}' ], + [ '{"a":"c"}/*c*/', '{"a":"c"}' ], + [ '/*c//d*/{"c":"b"}', '{"c":"b"}' ], + [ '{/*c*/"c":"b"}', '{"c":"b"}' ], + [ "/*\nc\r\n*/{\"c\":\"b\"}", '{"c":"b"}' ], + [ "//c\n{\"c\":\"b\"}", '{"c":"b"}' ], + [ "//c\r\n{\"c\":\"b\"}", '{"c":"b"}' ], + [ '{"a":"c"}//c', '{"a":"c"}' ], + [ "{\"a-c\"://c\n\"b\"}", '{"a-c":"b"}' ], + [ '{"/*a":"b"}', '{"/*a":"b"}' ], + [ '{"a":"//b"}', '{"a":"//b"}' ], + [ '{"a":"b/*c*/"}', '{"a":"b/*c*/"}' ], + [ "{\"\\\"/*a\":\"b\"}", "{\"\\\"/*a\":\"b\"}" ], + [ '', '' ], + [ '/*c', '' ], + [ '//c', '' ], + [ '"http://example.com"', '"http://example.com"' ], + [ "\0", "\0" ], + [ '"BlÃ¥bærsyltetøy"', '"BlÃ¥bærsyltetøy"' ], + ]; + } + + /** + * @covers FormatJson::stripComments + * @dataProvider provideStripComments + * @param string $json + * @param string $expect + */ + public function testStripComments( $json, $expect ) { + $this->assertSame( $expect, FormatJson::stripComments( $json ) ); + } + + public function provideParseStripComments() { + return [ + [ '/* blah */true', true ], + [ "// blah \ntrue", true ], + [ '[ "a" , /* blah */ "b" ]', [ 'a', 'b' ] ], + ]; + } + + /** + * @covers FormatJson::parse + * @covers FormatJson::stripComments + * @dataProvider provideParseStripComments + * @param string $json + * @param mixed $expect + */ + public function testParseStripComments( $json, $expect ) { + $st = FormatJson::parse( $json, FormatJson::STRIP_COMMENTS ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expect, $st->getValue() ); + } + + /** + * Generate a set of test cases for a particular combination of encoder options. + * + * @param array $unescapedGroups List of character groups to leave unescaped + * @return array Arrays of unencoded strings and corresponding encoded strings + */ + private static function getEncodeTestCases( array $unescapedGroups ) { + $groups = [ + 'always' => [ + // Forward slash (always unescaped) + '/' => '/', + + // Control characters + "\0" => '\u0000', + "\x08" => '\b', + "\t" => '\t', + "\n" => '\n', + "\r" => '\r', + "\f" => '\f', + "\x1f" => '\u001f', // representative example + + // Double quotes + '"' => '\"', + + // Backslashes + '\\' => '\\\\', + '\\\\' => '\\\\\\\\', + '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping + + // Line terminators + "\xe2\x80\xa8" => '\u2028', + "\xe2\x80\xa9" => '\u2029', + ], + 'unicode' => [ + "\xc3\xa9" => '\u00e9', + "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP + ], + 'xmlmeta' => [ + '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits + '>' => '\u003E', + '&' => '\u0026', + ], + ]; + + $cases = []; + foreach ( $groups as $name => $rules ) { + $leaveUnescaped = in_array( $name, $unescapedGroups ); + foreach ( $rules as $from => $to ) { + $cases[] = [ $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ]; + } + } + + return $cases; + } + + public function provideEmptyJsonKeyStrings() { + return [ + [ + '{"":"foo"}', + '{"":"foo"}', + '' + ], + [ + '{"_empty_":"foo"}', + '{"_empty_":"foo"}', + '_empty_' ], + [ + '{"\u005F\u0065\u006D\u0070\u0074\u0079\u005F":"foo"}', + '{"_empty_":"foo"}', + '_empty_' + ], + [ + '{"_empty_":"bar","":"foo"}', + '{"_empty_":"bar","":"foo"}', + '' + ], + [ + '{"":"bar","_empty_":"foo"}', + '{"":"bar","_empty_":"foo"}', + '_empty_' + ] + ]; + } + + /** + * @covers FormatJson::encode + * @covers FormatJson::decode + * @dataProvider provideEmptyJsonKeyStrings + * @param string $json + * + * Decoding behavior with empty keys can be surprising. + * See https://phabricator.wikimedia.org/T206411 + */ + public function testEmptyJsonKeyArray( $json, $expect, $php71Name ) { + // Decoding to array is consistent across supported PHP versions + $this->assertSame( $expect, FormatJson::encode( + FormatJson::decode( $json, true ) ) ); + + // Decoding to object differs between supported PHP versions + $obj = FormatJson::decode( $json ); + if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { + $this->assertEquals( 'foo', $obj->_empty_ ); + } else { + $this->assertEquals( 'foo', $obj->{$php71Name} ); + } + } +} diff --git a/tests/phpunit/unit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/tests/phpunit/unit/includes/libs/objectcache/ReplicatedBagOStuffTest.php new file mode 100644 index 0000000000..64d282f487 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/objectcache/ReplicatedBagOStuffTest.php @@ -0,0 +1,62 @@ +writeCache = new HashBagOStuff(); + $this->readCache = new HashBagOStuff(); + $this->cache = new ReplicatedBagOStuff( [ + 'writeFactory' => $this->writeCache, + 'readFactory' => $this->readCache, + ] ); + } + + /** + * @covers ReplicatedBagOStuff::set + */ + public function testSet() { + $key = 'a key'; + $value = 'a value'; + $this->cache->set( $key, $value ); + + // Write to master. + $this->assertEquals( $value, $this->writeCache->get( $key ) ); + // Don't write to replica. Replication is deferred to backend. + $this->assertFalse( $this->readCache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGet() { + $key = 'a key'; + + $write = 'one value'; + $this->writeCache->set( $key, $write ); + $read = 'another value'; + $this->readCache->set( $key, $read ); + + // Read from replica. + $this->assertEquals( $read, $this->cache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGetAbsent() { + $key = 'a key'; + $value = 'a value'; + $this->writeCache->set( $key, $value ); + + // Don't read from master. No failover if value is absent. + $this->assertFalse( $this->cache->get( $key ) ); + } +} diff --git a/tests/phpunit/unit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/unit/includes/media/GIFMetadataExtractorTest.php new file mode 100644 index 0000000000..10c450d935 --- /dev/null +++ b/tests/phpunit/unit/includes/media/GIFMetadataExtractorTest.php @@ -0,0 +1,110 @@ +mediaPath = __DIR__ . '/../../../data/media/'; + } + + /** + * Put in a file, and see if the metadata coming out is as expected. + * @param string $filename + * @param array $expected The extracted metadata. + * @dataProvider provideGetMetadata + * @covers GIFMetadataExtractor::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + $xmpNugget = << + + + + + The interwebs + + + + Bawolff + + + A file to test GIF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +EOF; + $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat + + return [ + [ + 'nonanimated.gif', + [ + 'comment' => [ 'GIF test file ⁕ Created with GIMP' ], + 'duration' => 0.1, + 'frameCount' => 1, + 'looped' => false, + 'xmp' => '', + ] + ], + [ + 'animated.gif', + [ + 'comment' => [ 'GIF test file . Created with GIMP' ], + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'xmp' => '', + ] + ], + + [ + 'animated-xmp.gif', + [ + 'xmp' => $xmpNugget, + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'comment' => [ 'GIƒ·test·file' ], + ] + ], + ]; + } +} diff --git a/tests/phpunit/unit/includes/media/IPTCTest.php b/tests/phpunit/unit/includes/media/IPTCTest.php new file mode 100644 index 0000000000..430493cd20 --- /dev/null +++ b/tests/phpunit/unit/includes/media/IPTCTest.php @@ -0,0 +1,85 @@ +assertEquals( 'UTF-8', $res ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharset88591() { + // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 + // This data doesn't specify a charset. We're supposed to guess + // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharset88591b() { + /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ + /* \xC3 = Ã, \xB8 = ¸ */ + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ 'ÃÃø' ], $res['Keywords'] ); + } + + /** + * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. + * What should happen is the first "\xC3\xC3" should be dropped as invalid, + * leaving \xC3\xB8, which is ø + * @covers IPTC::parse + */ + public function testIPTCParseForcedUTFButInvalid() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" + . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ 'ø' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharsetUTF8() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } + + /** + * Testing something that has 2 values for keyword + * @covers IPTC::parse + */ + public function testIPTCParseMulti() { + $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" + /* length */ . "\0\0\0\0\0\x0D" + . "\x1c\x02\x19" . "\x00\x01" . "\xBC" + . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼', '¼½' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseUTF8() { + // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. + $iptcData = + "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } +} diff --git a/tests/phpunit/unit/includes/media/MediaHandlerTest.php b/tests/phpunit/unit/includes/media/MediaHandlerTest.php new file mode 100644 index 0000000000..eb4ece88c4 --- /dev/null +++ b/tests/phpunit/unit/includes/media/MediaHandlerTest.php @@ -0,0 +1,68 @@ +assertEquals( $expected, + $result, + "($width, $height, $max) wanted: {$expected}x$y, got: {z$result}x$y2" ); + } + + public static function provideTestFitBoxWidth() { + return array_merge( + static::generateTestFitBoxWidthData( 50, 50, [ + 50 => 50, + 17 => 17, + 18 => 18 ] + ), + static::generateTestFitBoxWidthData( 366, 300, [ + 50 => 61, + 17 => 21, + 18 => 22 ] + ), + static::generateTestFitBoxWidthData( 300, 366, [ + 50 => 41, + 17 => 14, + 18 => 15 ] + ), + static::generateTestFitBoxWidthData( 100, 400, [ + 50 => 12, + 17 => 4, + 18 => 4 ] + ) + ); + } + + /** + * Generate single test cases by combining the dimensions and tests contents + * + * It creates: + * [$width, $height, $max, $expected], + * [$width, $height, $max2, $expected2], ... + * out of parameters: + * $width, $height, { $max => $expected, $max2 => $expected2, ... } + * + * @param int $width + * @param int $height + * @param array $tests associative array of $max => $expected values + * @return array + */ + private static function generateTestFitBoxWidthData( $width, $height, $tests ) { + $result = []; + foreach ( $tests as $max => $expected ) { + $result[] = [ $width, $height, $max, $expected ]; + } + return $result; + } +} diff --git a/tests/phpunit/unit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/unit/includes/media/SVGMetadataExtractorTest.php new file mode 100644 index 0000000000..30d10083f4 --- /dev/null +++ b/tests/phpunit/unit/includes/media/SVGMetadataExtractorTest.php @@ -0,0 +1,201 @@ +assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgFilesWithXMLMetadata + */ + public function testGetXMLMetadata( $infile, $expected ) { + $r = new XMLReader(); + $this->assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgUnits + */ + public function testScaleSVGUnit( $inUnit, $expected ) { + $this->assertEquals( + $expected, + SVGReader::scaleSVGUnit( $inUnit ), + 'SVG unit conversion and scaling failure' + ); + } + + function assertMetadata( $infile, $expected ) { + try { + $data = SVGMetadataExtractor::getMetadata( $infile ); + $this->assertEquals( $expected, $data, 'SVG metadata extraction test' ); + } catch ( MWException $e ) { + if ( $expected === false ) { + $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' ); + } else { + throw $e; + } + } + } + + public static function provideSvgFiles() { + $base = __DIR__ . '/../../../data/media'; + + return [ + [ + "$base/Wikimedia-logo.svg", + [ + 'width' => 1024, + 'height' => 1024, + 'originalWidth' => '1024', + 'originalHeight' => '1024', + 'translations' => [], + ] + ], + [ + "$base/QA_icon.svg", + [ + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60', + 'originalHeight' => '60', + 'translations' => [], + ] + ], + [ + "$base/Gtk-media-play-ltr.svg", + [ + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60.0000000', + 'originalHeight' => '60.0000000', + 'translations' => [], + ] + ], + [ + "$base/Toll_Texas_1.svg", + // This file triggered T33719, needs entity expansion in the xmlns checks + [ + 'width' => 385, + 'height' => 385, + 'originalWidth' => '385', + 'originalHeight' => '385.0004883', + 'translations' => [], + ] + ], + [ + "$base/Tux.svg", + [ + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'title' => 'Tux', + 'translations' => [], + 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ] + ], + [ + "$base/Speech_bubbles.svg", + [ + 'width' => 627, + 'height' => 461, + 'originalWidth' => '17.7cm', + 'originalHeight' => '13cm', + 'translations' => [ + 'de' => SVGReader::LANG_FULL_MATCH, + 'fr' => SVGReader::LANG_FULL_MATCH, + 'nl' => SVGReader::LANG_FULL_MATCH, + 'tlh-ca' => SVGReader::LANG_FULL_MATCH, + 'tlh' => SVGReader::LANG_PREFIX_MATCH + ], + ] + ], + [ + "$base/Soccer_ball_animated.svg", + [ + 'width' => 150, + 'height' => 150, + 'originalWidth' => '150', + 'originalHeight' => '150', + 'animated' => true, + 'translations' => [] + ], + ], + [ + "$base/comma_separated_viewbox.svg", + [ + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'translations' => [] + ], + ], + ]; + } + + public static function provideSvgFilesWithXMLMetadata() { + $base = __DIR__ . '/../../../data/media'; + // phpcs:disable Generic.Files.LineLength + $metadata = ' + + image/svg+xml + + + '; + // phpcs:enable + + $metadata = str_replace( "\r", '', $metadata ); // Windows compat + return [ + [ + "$base/US_states_by_total_state_tax_revenue.svg", + [ + 'height' => 593, + 'metadata' => $metadata, + 'width' => 959, + 'originalWidth' => '958.69', + 'originalHeight' => '592.78998', + 'translations' => [], + ] + ], + ]; + } + + public static function provideSvgUnits() { + return [ + [ '1' , 1 ], + [ '1.1' , 1.1 ], + [ '0.1' , 0.1 ], + [ '.1' , 0.1 ], + [ '1e2' , 100 ], + [ '1E2' , 100 ], + [ '+1' , 1 ], + [ '-1' , -1 ], + [ '-1.1' , -1.1 ], + [ '1e+2' , 100 ], + [ '1e-2' , 0.01 ], + [ '10px' , 10 ], + [ '10pt' , 10 * 1.25 ], + [ '10pc' , 10 * 15 ], + [ '10mm' , 10 * 3.543307 ], + [ '10cm' , 10 * 35.43307 ], + [ '10in' , 10 * 90 ], + [ '10em' , 10 * 16 ], + [ '10ex' , 10 * 12 ], + [ '10%' , 51.2 ], + [ '10 px' , 10 ], + // Invalid values + [ '1e1.1', 10 ], + [ '10bp', 10 ], + [ 'p10', null ], + ]; + } +} diff --git a/tests/phpunit/unit/includes/objectcache/MemcachedBagOStuffTest.php b/tests/phpunit/unit/includes/objectcache/MemcachedBagOStuffTest.php new file mode 100644 index 0000000000..eb040b4501 --- /dev/null +++ b/tests/phpunit/unit/includes/objectcache/MemcachedBagOStuffTest.php @@ -0,0 +1,107 @@ +cache = new MemcachedPhpBagOStuff( [ 'keyspace' => 'test', 'servers' => [] ] ); + } + + /** + * @covers MemcachedBagOStuff::makeKey + */ + public function testKeyNormalization() { + $this->assertEquals( + 'test:vanilla', + $this->cache->makeKey( 'vanilla' ) + ); + + $this->assertEquals( + 'test:punctuation_marks_are_ok:!@$^&*()', + $this->cache->makeKey( 'punctuation_marks_are_ok', '!@$^&*()' ) + ); + + $this->assertEquals( + 'test:but_spaces:hashes%23:and%0Anewlines:are_not', + $this->cache->makeKey( 'but spaces', 'hashes#', "and\nnewlines", 'are_not' ) + ); + + $this->assertEquals( + 'test:this:key:contains:%F0%9D%95%9E%F0%9D%95%A6%F0%9D%95%9D%F0%9D%95%A5%F0%9' . + 'D%95%9A%F0%9D%95%93%F0%9D%95%AA%F0%9D%95%A5%F0%9D%95%96:characters', + $this->cache->makeKey( 'this', 'key', 'contains', '𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖', 'characters' ) + ); + + $this->assertEquals( + 'test:this:key:contains:#c118f92685a635cb843039de50014c9c', + $this->cache->makeKey( 'this', 'key', 'contains', '𝕥𝕠𝕠 𝕞𝕒𝕟𝕪 𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖 𝕔𝕙𝕒𝕣𝕒𝕔𝕥𝕖𝕣𝕤' ) + ); + + $this->assertEquals( + 'test:BagOStuff-long-key:##dc89dcb43b28614da27660240af478b5', + $this->cache->makeKey( '𝕖𝕧𝕖𝕟', '𝕚𝕗', '𝕨𝕖', '𝕄𝔻𝟝', '𝕖𝕒𝕔𝕙', + '𝕒𝕣𝕘𝕦𝕞𝕖𝕟𝕥', '𝕥𝕙𝕚𝕤', '𝕜𝕖𝕪', '𝕨𝕠𝕦𝕝𝕕', '𝕤𝕥𝕚𝕝𝕝', '𝕓𝕖', '𝕥𝕠𝕠', '𝕝𝕠𝕟𝕘' ) + ); + + $this->assertEquals( + 'test:%23%235820ad1d105aa4dc698585c39df73e19', + $this->cache->makeKey( '##5820ad1d105aa4dc698585c39df73e19' ) + ); + + $this->assertEquals( + 'test:percent_is_escaped:!@$%25^&*()', + $this->cache->makeKey( 'percent_is_escaped', '!@$%^&*()' ) + ); + + $this->assertEquals( + 'test:colon_is_escaped:!@$%3A^&*()', + $this->cache->makeKey( 'colon_is_escaped', '!@$:^&*()' ) + ); + + $this->assertEquals( + 'test:long_key_part_hashed:#0244f7b1811d982dd932dd7de01465ac', + $this->cache->makeKey( 'long_key_part_hashed', str_repeat( 'y', 500 ) ) + ); + } + + /** + * @dataProvider validKeyProvider + * @covers MemcachedBagOStuff::validateKeyEncoding + */ + public function testValidateKeyEncoding( $key ) { + $this->assertSame( $key, $this->cache->validateKeyEncoding( $key ) ); + } + + public function validKeyProvider() { + return [ + 'empty' => [ '' ], + 'digits' => [ '09' ], + 'letters' => [ 'AZaz' ], + 'ASCII special characters' => [ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' ], + ]; + } + + /** + * @dataProvider invalidKeyProvider + * @covers MemcachedBagOStuff::validateKeyEncoding + */ + public function testValidateKeyEncodingThrowsException( $key ) { + $this->setExpectedException( Exception::class ); + $this->cache->validateKeyEncoding( $key ); + } + + public function invalidKeyProvider() { + return [ + [ "\x00" ], + [ ' ' ], + [ "\x1F" ], + [ "\x7F" ], + [ "\x80" ], + [ "\xFF" ], + ]; + } +} diff --git a/tests/phpunit/unit/includes/objectcache/RESTBagOStuffTest.php b/tests/phpunit/unit/includes/objectcache/RESTBagOStuffTest.php new file mode 100644 index 0000000000..459e3eebc2 --- /dev/null +++ b/tests/phpunit/unit/includes/objectcache/RESTBagOStuffTest.php @@ -0,0 +1,96 @@ +client = + $this->getMockBuilder( MultiHttpClient::class ) + ->setConstructorArgs( [ [] ] ) + ->setMethods( [ 'run' ] ) + ->getMock(); + $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] ); + } + + public function testGet() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], '"somedata"', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertEquals( 'somedata', $result ); + } + + public function testGetNotExist() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + } + + public function testGetBadClient() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() ); + } + + public function testGetBadServer() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() ); + } + + public function testPut() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'PUT', + 'url' => 'http://test/rest/42xyz42', + 'body' => '"postdata"', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->set( '42xyz42', 'postdata' ); + $this->assertTrue( $result ); + } + + public function testDelete() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'DELETE', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->delete( '42xyz42' ); + $this->assertTrue( $result ); + } +} diff --git a/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php b/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php new file mode 100644 index 0000000000..71e4fffb4d --- /dev/null +++ b/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php @@ -0,0 +1,352 @@ +assertEquals( + "\xc3\xa9cole", + Sanitizer::decodeCharReferences( 'école' ), + 'decode named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeNumericEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode numeric entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode mixed numeric/named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedComplexEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas Ĉio dans l'école)", + Sanitizer::decodeCharReferences( + "Ĉio bonas dans l'école! (mais pas &#x108;io dans l'&eacute;cole)" + ), + 'decode mixed complex entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidAmpersand() { + $this->assertEquals( + 'a & b', + Sanitizer::decodeCharReferences( 'a & b' ), + 'Invalid ampersand' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidEntities() { + $this->assertEquals( + '&foo;', + Sanitizer::decodeCharReferences( '&foo;' ), + 'Invalid named entity' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidNumberedEntities() { + $this->assertEquals( + UtfNormal\Constants::UTF8_REPLACEMENT, + Sanitizer::decodeCharReferences( "�" ), + 'Invalid numbered entity' + ); + } + + /** + * @dataProvider provideTagAttributesToDecode + * @covers Sanitizer::decodeTagAttributes + */ + public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::decodeTagAttributes( $attributes ), + $message + ); + } + + public static function provideTagAttributesToDecode() { + return [ + [ [ 'foo' => 'bar' ], 'foo=bar', 'Unquoted attribute' ], + [ [ 'עברית' => 'bar' ], 'עברית=bar', 'Non-Latin attribute' ], + [ [ '६' => 'bar' ], '६=bar', 'Devanagari number' ], + [ [ '搭𨋢' => 'bar' ], '搭𨋢=bar', 'Non-BMP character' ], + [ [], 'ńgh=bar', 'Combining accent is not allowed' ], + [ [ 'foo' => 'bar' ], ' foo = bar ', 'Spaced attribute' ], + [ [ 'foo' => 'bar' ], 'foo="bar"', 'Double-quoted attribute' ], + [ [ 'foo' => 'bar' ], 'foo=\'bar\'', 'Single-quoted attribute' ], + [ + [ 'foo' => 'bar', 'baz' => 'foo' ], + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ], + [ + [ 'foo' => 'bar', 'baz' => 'foo' ], + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ], + [ + [ 'foo' => 'bar', 'baz' => 'foo' ], + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ], + [ [ ':foo' => 'bar' ], ':foo=\'bar\'', 'Leading :' ], + [ [ '_foo' => 'bar' ], '_foo=\'bar\'', 'Leading _' ], + [ [ 'foo' => 'bar' ], 'Foo=\'bar\'', 'Leading capital' ], + [ [ 'foo' => 'BAR' ], 'FOO=BAR', 'Attribute keys are normalized to lowercase' ], + + # Invalid beginning + [ [], '-foo=bar', 'Leading - is forbidden' ], + [ [], '.foo=bar', 'Leading . is forbidden' ], + [ [ 'foo-bar' => 'bar' ], 'foo-bar=bar', 'A - is allowed inside the attribute' ], + [ [ 'foo-' => 'bar' ], 'foo-=bar', 'A - is allowed inside the attribute' ], + [ [ 'foo.bar' => 'baz' ], 'foo.bar=baz', 'A . is allowed inside the attribute' ], + [ [ 'foo.' => 'baz' ], 'foo.=baz', 'A . is allowed as last character' ], + [ [ 'foo6' => 'baz' ], 'foo6=baz', 'Numbers are allowed' ], + + # This bit is more relaxed than XML rules, but some extensions use + # it, like ProofreadPage (see T29539) + [ [ '1foo' => 'baz' ], '1foo=baz', 'Leading numbers are allowed' ], + [ [], 'foo$=baz', 'Symbols are not allowed' ], + [ [], 'foo@=baz', 'Symbols are not allowed' ], + [ [], 'foo~=baz', 'Symbols are not allowed' ], + [ + [ 'foo' => '1[#^`*%w/(' ], + 'foo=1[#^`*%w/(', + 'All kind of characters are allowed as values' + ], + [ + [ 'foo' => '1[#^`*%\'w/(' ], + 'foo="1[#^`*%\'w/("', + 'Double quotes are allowed if quoted by single quotes' + ], + [ + [ 'foo' => '1[#^`*%"w/(' ], + 'foo=\'1[#^`*%"w/(\'', + 'Single quotes are allowed if quoted by double quotes' + ], + [ [ 'foo' => '&"' ], 'foo=&"', 'Special chars can be provided as entities' ], + [ [ 'foo' => '&foobar;' ], 'foo=&foobar;', 'Entity-like items are accepted' ], + ]; + } + + /** + * @dataProvider provideCssCommentsFixtures + * @covers Sanitizer::checkCss + */ + public function testCssCommentsChecking( $expected, $css, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::checkCss( $css ), + $message + ); + } + + public static function provideCssCommentsFixtures() { + /** [ , , [message] ] */ + return [ + // Valid comments spanning entire input + [ '/**/', '/**/' ], + [ '/* comment */', '/* comment */' ], + // Weird stuff + [ ' ', '/****/' ], + [ ' ', '/* /* */' ], + [ 'display: block;', "display:/* foo */block;" ], + [ 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;", + 'Backslash-escaped comments must be stripped (T30450)' ], + [ '', '/* unfinished comment structure', + 'Remove anything after a comment-start token' ], + [ '', "\\2f\\2a unifinished comment'", + 'Remove anything after a backslash-escaped comment-start token' ], + [ + '/* insecure input */', + 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\');' + ], + [ + '/* insecure input */', + '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\')";' + ], + [ '/* insecure input */', 'width: expression(1+1);' ], + [ '/* insecure input */', 'background-image: image(asdf.png);' ], + [ '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ], + [ '/* insecure input */', 'background-image: -moz-image(asdf.png);' ], + [ '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ], + [ + '/* insecure input */', + 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);' + ], + [ + '/* insecure input */', + 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' + ], + [ '/* insecure input */', 'foo: attr( title, url );' ], + [ '/* insecure input */', 'foo: attr( title url );' ], + [ '/* insecure input */', 'foo: var(--evil-attribute)' ], + ]; + } + + /** + * @dataProvider provideEscapeHtmlAllowEntities + * @covers Sanitizer::escapeHtmlAllowEntities + */ + public function testEscapeHtmlAllowEntities( $expected, $html ) { + $this->assertEquals( + $expected, + Sanitizer::escapeHtmlAllowEntities( $html ) + ); + } + + public static function provideEscapeHtmlAllowEntities() { + return [ + [ 'foo', 'foo' ], + [ 'a¡b', 'a¡b' ], + [ 'foo'bar', "foo'bar" ], + [ '<script>foo</script>', '' ], + ]; + } + + /** + * Test Sanitizer::escapeId + * + * @dataProvider provideEscapeId + * @covers Sanitizer::escapeId + */ + public function testEscapeId( $input, $output ) { + $this->assertEquals( + $output, + Sanitizer::escapeId( $input, [ 'noninitial', 'legacy' ] ) + ); + } + + public static function provideEscapeId() { + return [ + [ '+', '.2B' ], + [ '&', '.26' ], + [ '=', '.3D' ], + [ ':', ':' ], + [ ';', '.3B' ], + [ '@', '.40' ], + [ '$', '.24' ], + [ '-_.', '-_.' ], + [ '!', '.21' ], + [ '*', '.2A' ], + [ '/', '.2F' ], + [ '[]', '.5B.5D' ], + [ '<>', '.3C.3E' ], + [ '\'', '.27' ], + [ '§', '.C2.A7' ], + [ 'Test:A & B/Here', 'Test:A_.26_B.2FHere' ], + [ 'A&B&C&amp;D&amp;amp;E', 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' ], + ]; + } + + /** + * Test escapeIdReferenceList for consistency with escapeIdForAttribute + * + * @dataProvider provideEscapeIdReferenceList + * @covers Sanitizer::escapeIdReferenceList + */ + public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) { + $this->assertEquals( + Sanitizer::escapeIdReferenceList( $referenceList ), + Sanitizer::escapeIdForAttribute( $id1 ) + . ' ' + . Sanitizer::escapeIdForAttribute( $id2 ) + ); + } + + public static function provideEscapeIdReferenceList() { + /** [ , , ] */ + return [ + [ 'foo bar', 'foo', 'bar' ], + [ '#1 #2', '#1', '#2' ], + [ '+1 +2', '+1', '+2' ], + ]; + } + + /** + * @dataProvider provideIsReservedDataAttribute + * @covers Sanitizer::isReservedDataAttribute + */ + public function testIsReservedDataAttribute( $attr, $expected ) { + $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) ); + } + + public static function provideIsReservedDataAttribute() { + return [ + [ 'foo', false ], + [ 'data', false ], + [ 'data-foo', false ], + [ 'data-mw', true ], + [ 'data-ooui', true ], + [ 'data-parsoid', true ], + [ 'data-mw-foo', true ], + [ 'data-ooui-foo', true ], + [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently + ]; + } + + /** + * @dataProvider provideStripAllTags + * + * @covers Sanitizer::stripAllTags() + * @covers RemexStripTagHandler + * + * @param string $input + * @param string $expected + */ + public function testStripAllTags( $input, $expected ) { + $this->assertEquals( $expected, Sanitizer::stripAllTags( $input ) ); + } + + public function provideStripAllTags() { + return [ + [ '

    Foo

    ', 'Foo' ], + [ '

    Foo

    Bar

    ', 'Foo Bar' ], + [ "

    Foo

    \n

    Bar

    ", 'Foo Bar' ], + [ '

    Hello <strong> world café

    ', 'Hello world café' ], + [ + '

    quux\'>Bar Whee!

    ', + 'Bar Whee!' + ], + [ '123', '123' ], + [ '123', '123' ], + [ '12', '1 2' ], + ]; + } + +} diff --git a/tests/phpunit/unit/includes/parser/TidyTest.php b/tests/phpunit/unit/includes/parser/TidyTest.php new file mode 100644 index 0000000000..1adb6a6444 --- /dev/null +++ b/tests/phpunit/unit/includes/parser/TidyTest.php @@ -0,0 +1,64 @@ +markTestSkipped( 'Tidy not found' ); + } + } + + /** + * @dataProvider provideTestWrapping + */ + public function testTidyWrapping( $expected, $text, $msg = '' ) { + $text = MWTidy::tidy( $text ); + // We don't care about where Tidy wants to stick is

    s + $text = trim( preg_replace( '##', '', $text ) ); + // Windows, we love you! + $text = str_replace( "\r", '', $text ); + $this->assertEquals( $expected, $text, $msg ); + } + + public static function provideTestWrapping() { + $testMathML = <<<'MathML' + + + a + + + x + 2 + + + + b + + x + + + c + + +MathML; + return [ + [ + 'foo', + 'foo', + ' should survive tidy' + ], + [ + 'foo', + 'foo', + ' should survive tidy' + ], + [ 'foo', 'foo', ' should survive tidy' ], + [ "foo", 'foo', ' should survive tidy' ], + [ "foo", 'foo', ' should survive tidy' ], + [ $testMathML, $testMathML, ' should survive tidy' ], + ]; + } +} diff --git a/tests/phpunit/unit/includes/password/PasswordFactoryTest.php b/tests/phpunit/unit/includes/password/PasswordFactoryTest.php new file mode 100644 index 0000000000..cbfddd4d72 --- /dev/null +++ b/tests/phpunit/unit/includes/password/PasswordFactoryTest.php @@ -0,0 +1,124 @@ +assertEquals( [ '' ], array_keys( $pf->getTypes() ) ); + $this->assertEquals( '', $pf->getDefaultType() ); + + $pf = new PasswordFactory( [ + 'foo' => [ 'class' => 'FooPassword' ], + 'bar' => [ 'class' => 'BarPassword', 'baz' => 'boom' ], + ], 'foo' ); + $this->assertEquals( [ '', 'foo', 'bar' ], array_keys( $pf->getTypes() ) ); + $this->assertArraySubset( [ 'class' => 'BarPassword', 'baz' => 'boom' ], $pf->getTypes()['bar'] ); + $this->assertEquals( 'foo', $pf->getDefaultType() ); + } + + public function testRegister() { + $pf = new PasswordFactory; + $pf->register( 'foo', [ 'class' => InvalidPassword::class ] ); + $this->assertArrayHasKey( 'foo', $pf->getTypes() ); + } + + public function testSetDefaultType() { + $pf = new PasswordFactory; + $pf->register( '1', [ 'class' => InvalidPassword::class ] ); + $pf->register( '2', [ 'class' => InvalidPassword::class ] ); + $pf->setDefaultType( '1' ); + $this->assertSame( '1', $pf->getDefaultType() ); + $pf->setDefaultType( '2' ); + $this->assertSame( '2', $pf->getDefaultType() ); + } + + /** + * @expectedException Exception + */ + public function testSetDefaultTypeError() { + $pf = new PasswordFactory; + $pf->setDefaultType( 'bogus' ); + } + + public function testInit() { + $config = new HashConfig( [ + 'PasswordConfig' => [ + 'foo' => [ 'class' => InvalidPassword::class ], + ], + 'PasswordDefault' => 'foo' + ] ); + $pf = new PasswordFactory; + $pf->init( $config ); + $this->assertSame( 'foo', $pf->getDefaultType() ); + $this->assertArrayHasKey( 'foo', $pf->getTypes() ); + } + + public function testNewFromCiphertext() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pw = $pf->newFromCiphertext( ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23' ); + $this->assertInstanceOf( MWSaltedPassword::class, $pw ); + } + + public function provideNewFromCiphertextErrors() { + return [ [ 'blah' ], [ ':blah:' ] ]; + } + + /** + * @dataProvider provideNewFromCiphertextErrors + * @expectedException PasswordError + */ + public function testNewFromCiphertextErrors( $hash ) { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->newFromCiphertext( $hash ); + } + + public function testNewFromType() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pw = $pf->newFromType( 'B' ); + $this->assertInstanceOf( MWSaltedPassword::class, $pw ); + } + + /** + * @expectedException PasswordError + */ + public function testNewFromTypeError() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->newFromType( 'bogus' ); + } + + public function testNewFromPlaintext() { + $pf = new PasswordFactory; + $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->setDefaultType( 'A' ); + + $this->assertInstanceOf( InvalidPassword::class, $pf->newFromPlaintext( null ) ); + $this->assertInstanceOf( MWOldPassword::class, $pf->newFromPlaintext( 'password' ) ); + $this->assertInstanceOf( MWSaltedPassword::class, + $pf->newFromPlaintext( 'password', $pf->newFromType( 'B' ) ) ); + } + + public function testNeedsUpdate() { + $pf = new PasswordFactory; + $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->setDefaultType( 'A' ); + + $this->assertFalse( $pf->needsUpdate( $pf->newFromType( 'A' ) ) ); + $this->assertTrue( $pf->needsUpdate( $pf->newFromType( 'B' ) ) ); + } + + public function testGenerateRandomPasswordString() { + $this->assertSame( 13, strlen( PasswordFactory::generateRandomPasswordString( 13 ) ) ); + } + + public function testNewInvalidPassword() { + $this->assertInstanceOf( InvalidPassword::class, PasswordFactory::newInvalidPassword() ); + } +} diff --git a/tests/phpunit/unit/includes/password/PasswordTest.php b/tests/phpunit/unit/includes/password/PasswordTest.php new file mode 100644 index 0000000000..b41c0f4150 --- /dev/null +++ b/tests/phpunit/unit/includes/password/PasswordTest.php @@ -0,0 +1,33 @@ +newFromPlaintext( null ); + + $this->assertInstanceOf( InvalidPassword::class, $invalid ); + } +} diff --git a/tests/phpunit/unit/includes/preferences/FiltersTest.php b/tests/phpunit/unit/includes/preferences/FiltersTest.php new file mode 100644 index 0000000000..d2b5d05bb3 --- /dev/null +++ b/tests/phpunit/unit/includes/preferences/FiltersTest.php @@ -0,0 +1,141 @@ +filterFromForm( '0' ) ); + self::assertSame( 3, $filter->filterFromForm( '3' ) ); + self::assertSame( '123', $filter->filterForForm( '123' ) ); + } + + /** + * @covers MediaWiki\Preferences\TimezoneFilter::filterFromForm() + * @dataProvider provideTimezoneFilter + * + * @param string $input + * @param string $expected + */ + public function testTimezoneFilter( $input, $expected ) { + $filter = new TimezoneFilter(); + $result = $filter->filterFromForm( $input ); + self::assertEquals( $expected, $result ); + } + + public function provideTimezoneFilter() { + return [ + [ 'ZoneInfo', 'Offset|0' ], + [ 'ZoneInfo|bogus', 'Offset|0' ], + [ 'System', 'System' ], + [ '2:30', 'Offset|150' ], + ]; + } + + /** + * @covers MediaWiki\Preferences\MultiUsernameFilter::filterFromForm() + * @dataProvider provideMultiUsernameFilterFrom + * + * @param string $input + * @param string|null $expected + */ + public function testMultiUsernameFilterFrom( $input, $expected ) { + $filter = $this->makeMultiUsernameFilter(); + $result = $filter->filterFromForm( $input ); + self::assertSame( $expected, $result ); + } + + public function provideMultiUsernameFilterFrom() { + return [ + [ '', null ], + [ "\n\n\n", null ], + [ 'Foo', '1' ], + [ "\n\n\nFoo\nBar\n", "1\n2" ], + [ "Baz\nInvalid\nFoo", "3\n1" ], + [ "Invalid", null ], + [ "Invalid\n\n\nInvalid\n", null ], + ]; + } + + /** + * @covers MediaWiki\Preferences\MultiUsernameFilter::filterForForm() + * @dataProvider provideMultiUsernameFilterFor + * + * @param string $input + * @param string $expected + */ + public function testMultiUsernameFilterFor( $input, $expected ) { + $filter = $this->makeMultiUsernameFilter(); + $result = $filter->filterForForm( $input ); + self::assertSame( $expected, $result ); + } + + public function provideMultiUsernameFilterFor() { + return [ + [ '', '' ], + [ "\n", '' ], + [ '1', 'Foo' ], + [ "\n1\n\n2\377\n", "Foo\nBar" ], + [ "666\n667", '' ], + ]; + } + + private function makeMultiUsernameFilter() { + $userMapping = [ + 'Foo' => 1, + 'Bar' => 2, + 'Baz' => 3, + ]; + $flipped = array_flip( $userMapping ); + $idLookup = self::getMockBuilder( CentralIdLookup::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'centralIdsFromNames', 'namesFromCentralIds' ] ) + ->getMockForAbstractClass(); + + $idLookup->method( 'centralIdsFromNames' ) + ->will( self::returnCallback( function ( $names ) use ( $userMapping ) { + $ids = []; + foreach ( $names as $name ) { + $ids[] = $userMapping[$name] ?? null; + } + return array_filter( $ids, 'is_numeric' ); + } ) ); + $idLookup->method( 'namesFromCentralIds' ) + ->will( self::returnCallback( function ( $ids ) use ( $flipped ) { + $names = []; + foreach ( $ids as $id ) { + $names[] = $flipped[$id] ?? null; + } + return array_filter( $names, 'is_string' ); + } ) ); + + return new MultiUsernameFilter( $idLookup ); + } +} diff --git a/tests/phpunit/unit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/unit/includes/registration/ExtensionProcessorTest.php new file mode 100644 index 0000000000..13de142ae6 --- /dev/null +++ b/tests/phpunit/unit/includes/registration/ExtensionProcessorTest.php @@ -0,0 +1,829 @@ +dir = __DIR__ . '/FooBar/extension.json'; + $this->dirname = dirname( $this->dir ); + } + + /** + * 'name' is absolutely required + * + * @var array + */ + public static $default = [ + 'name' => 'FooBar', + ]; + + public function testExtractInfo() { + // Test that attributes that begin with @ are ignored + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default + [ + '@metadata' => [ 'foobarbaz' ], + 'AnAttribute' => [ 'omg' ], + 'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ], + 'SpecialPages' => [ 'Foo' => 'SpecialFoo' ], + 'callback' => 'FooBar::onRegistration', + ], 1 ); + + $extracted = $processor->getExtractedInfo(); + $attributes = $extracted['attributes']; + $this->assertArrayHasKey( 'AnAttribute', $attributes ); + $this->assertArrayNotHasKey( '@metadata', $attributes ); + $this->assertArrayNotHasKey( 'AutoloadClasses', $attributes ); + $this->assertSame( + [ 'FooBar' => 'FooBar::onRegistration' ], + $extracted['callbacks'] + ); + $this->assertSame( + [ 'Foo' => 'SpecialFoo' ], + $extracted['globals']['wgSpecialPages'] + ); + } + + public function testExtractNamespaces() { + // Test that namespace IDs can be overwritten + if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) { + define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 ); + } + + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default + [ + 'namespaces' => [ + [ + 'id' => 332200, + 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', + 'name' => 'Test_A', + 'defaultcontentmodel' => 'TestModel', + 'gender' => [ + 'male' => 'Male test', + 'female' => 'Female test', + ], + 'subpages' => true, + 'content' => true, + 'protection' => 'userright', + ], + [ // Test_X will use ID 123456 not 334400 + 'id' => 334400, + 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', + 'name' => 'Test_X', + 'defaultcontentmodel' => 'TestModel' + ], + ] + ], 1 ); + + $extracted = $processor->getExtractedInfo(); + + $this->assertArrayHasKey( + 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', + $extracted['defines'] + ); + $this->assertArrayNotHasKey( + 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', + $extracted['defines'] + ); + + $this->assertSame( + $extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A'], + 332200 + ); + + $this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] ); + $this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] ); + $this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] ); + $this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] ); + + $this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] ); + $this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] ); + $this->assertSame( + [ 'male' => 'Male test', 'female' => 'Female test' ], + $extracted['globals']['wgExtraGenderNamespaces'][332200] + ); + // A has subpages, X does not + $this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] ); + $this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] ); + } + + public static function provideRegisterHooks() { + $merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ]; + // Format: + // Current $wgHooks + // Content in extension.json + // Expected value of $wgHooks + return [ + // No hooks + [ + [], + self::$default, + $merge, + ], + // No current hooks, adding one for "FooBaz" in string format + [ + [], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, + ], + // Hook for "FooBaz", adding another one + [ + [ 'FooBaz' => [ 'PriorCallback' ] ], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge, + ], + // No current hooks, adding one for "FooBaz" in verbose array format + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default, + [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, + ], + // Hook for "BarBaz", adding one for "FooBaz" + [ + [ 'BarBaz' => [ 'BarBazCallback' ] ], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ + 'BarBaz' => [ 'BarBazCallback' ], + 'FooBaz' => [ 'FooBazCallback' ], + ] + $merge, + ], + // Callbacks for FooBaz wrapped in an array + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default, + [ + 'FooBaz' => [ 'Callback1' ], + ] + $merge, + ], + // Multiple callbacks for FooBaz hook + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default, + [ + 'FooBaz' => [ 'Callback1', 'Callback2' ], + ] + $merge, + ], + ]; + } + + /** + * @dataProvider provideRegisterHooks + */ + public function testRegisterHooks( $pre, $info, $expected ) { + $processor = new MockExtensionProcessor( [ 'wgHooks' => $pre ] ); + $processor->extractInfo( $this->dir, $info, 1 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( $expected, $extracted['globals']['wgHooks'] ); + } + + public function testExtractConfig1() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => 'somevalue', + 'Foo' => 10, + '@IGNORED' => 'yes', + ], + ] + self::$default; + $info2 = [ + 'config' => [ + '_prefix' => 'eg', + 'Bar' => 'somevalue' + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 1 ); + $processor->extractInfo( $this->dir, $info2, 1 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + $this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] ); + // Custom prefix: + $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); + } + + public function testExtractConfig2() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + 'Foo' => [ 'value' => 10 ], + 'Path' => [ 'value' => 'foo.txt', 'path' => true ], + 'Namespaces' => [ + 'value' => [ + '10' => true, + '12' => false, + ], + 'merge_strategy' => 'array_plus', + ], + ], + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ], + 'config_prefix' => 'eg', + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 2 ); + $processor->extractInfo( $this->dir, $info2, 2 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] ); + // Custom prefix: + $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); + $this->assertSame( + [ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ], + $extracted['globals']['wgNamespaces'] + ); + } + + /** + * @expectedException RuntimeException + */ + public function testDuplicateConfigKey1() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => '', + ] + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => 'g', + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 1 ); + $processor->extractInfo( $this->dir, $info2, 1 ); + } + + /** + * @expectedException RuntimeException + */ + public function testDuplicateConfigKey2() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ] + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 2 ); + $processor->extractInfo( $this->dir, $info2, 2 ); + } + + public static function provideExtractExtensionMessagesFiles() { + $dir = __DIR__ . '/FooBar/'; + return [ + [ + [ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ], + [ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ] + ], + [ + [ + 'ExtensionMessagesFiles' => [ + 'FooBarAlias' => 'FooBar.alias.php', + 'FooBarMagic' => 'FooBar.magic.i18n.php', + ], + ], + [ + 'wgExtensionMessagesFiles' => [ + 'FooBarAlias' => $dir . 'FooBar.alias.php', + 'FooBarMagic' => $dir . 'FooBar.magic.i18n.php', + ], + ], + ], + ]; + } + + /** + * @dataProvider provideExtractExtensionMessagesFiles + */ + public function testExtractExtensionMessagesFiles( $input, $expected ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expected as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + } + + public static function provideExtractMessagesDirs() { + $dir = __DIR__ . '/FooBar/'; + return [ + [ + [ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ], + [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ] + ], + [ + [ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ], + [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ] + ], + ]; + } + + /** + * @dataProvider provideExtractMessagesDirs + */ + public function testExtractMessagesDirs( $input, $expected ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expected as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + } + + public function testExtractCredits() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default, 1 ); + $this->setExpectedException( Exception::class ); + $processor->extractInfo( $this->dir, self::$default, 1 ); + } + + /** + * @dataProvider provideExtractResourceLoaderModules + */ + public function testExtractResourceLoaderModules( + $input, + array $expectedGlobals, + array $expectedAttribs = [] + ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expectedGlobals as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + foreach ( $expectedAttribs as $key => $value ) { + $this->assertEquals( $value, $out['attributes'][$key] ); + } + } + + public static function provideExtractResourceLoaderModules() { + $dir = __DIR__ . '/FooBar'; + return [ + // Generic module with localBasePath/remoteExtPath specified + [ + // Input + [ + 'ResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foobar.js', + 'localBasePath' => '', + 'remoteExtPath' => 'FooBar', + ], + ], + ], + // Expected + [ + 'wgResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foobar.js', + 'localBasePath' => $dir, + 'remoteExtPath' => 'FooBar', + ], + ], + ], + ], + // ResourceFileModulePaths specified: + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => 'modules', + 'remoteExtPath' => 'FooBar/modules', + ], + 'ResourceModules' => [ + // No paths + 'test.foo' => [ + 'styles' => 'foo.js', + ], + // Different paths set + 'test.bar' => [ + 'styles' => 'bar.js', + 'localBasePath' => 'subdir', + 'remoteExtPath' => 'FooBar/subdir', + ], + // Custom class with no paths set + 'test.class' => [ + 'class' => 'FooBarModule', + 'extra' => 'argument', + ], + // Custom class with a localBasePath + 'test.class.with.path' => [ + 'class' => 'FooBarPathModule', + 'extra' => 'argument', + 'localBasePath' => '', + ] + ], + ], + // Expected + [ + 'wgResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foo.js', + 'localBasePath' => "$dir/modules", + 'remoteExtPath' => 'FooBar/modules', + ], + 'test.bar' => [ + 'styles' => 'bar.js', + 'localBasePath' => "$dir/subdir", + 'remoteExtPath' => 'FooBar/subdir', + ], + 'test.class' => [ + 'class' => 'FooBarModule', + 'extra' => 'argument', + 'localBasePath' => "$dir/modules", + 'remoteExtPath' => 'FooBar/modules', + ], + 'test.class.with.path' => [ + 'class' => 'FooBarPathModule', + 'extra' => 'argument', + 'localBasePath' => $dir, + 'remoteExtPath' => 'FooBar/modules', + ] + ], + ], + ], + // ResourceModuleSkinStyles with file module paths + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => '', + 'remoteSkinPath' => 'FooBar', + ], + 'ResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + ] + ], + ], + // Expected + [ + 'wgResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'localBasePath' => $dir, + 'remoteSkinPath' => 'FooBar', + ], + ], + ], + ], + // ResourceModuleSkinStyles with file module paths and an override + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => '', + 'remoteSkinPath' => 'FooBar', + ], + 'ResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'remoteSkinPath' => 'BarFoo' + ], + ], + ], + // Expected + [ + 'wgResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'localBasePath' => $dir, + 'remoteSkinPath' => 'BarFoo', + ], + ], + ], + ], + 'QUnit test module' => [ + // Input + [ + 'QUnitTestModule' => [ + 'localBasePath' => '', + 'remoteExtPath' => 'Foo', + 'scripts' => 'bar.js', + ], + ], + // Expected + [], + [ + 'QUnitTestModules' => [ + 'test.FooBar' => [ + 'localBasePath' => $dir, + 'remoteExtPath' => 'Foo', + 'scripts' => 'bar.js', + ], + ], + ], + ], + ]; + } + + public static function provideSetToGlobal() { + return [ + [ + [ 'wgAPIModules', 'wgAvailableRights' ], + [], + [ + 'APIModules' => [ 'foobar' => 'ApiFooBar' ], + 'AvailableRights' => [ 'foobar', 'unfoobar' ], + ], + [ + 'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ], + 'wgAvailableRights' => [ 'foobar', 'unfoobar' ], + ], + ], + [ + [ 'wgAPIModules', 'wgAvailableRights' ], + [ + 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ], + 'wgAvailableRights' => [ 'barbaz' ] + ], + [ + 'APIModules' => [ 'foobar' => 'ApiFooBar' ], + 'AvailableRights' => [ 'foobar', 'unfoobar' ], + ], + [ + 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ], + 'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ], + ], + ], + [ + [ 'wgGroupPermissions' ], + [ + 'wgGroupPermissions' => [ + 'sysop' => [ 'delete' ] + ], + ], + [ + 'GroupPermissions' => [ + 'sysop' => [ 'undelete' ], + 'user' => [ 'edit' ] + ], + ], + [ + 'wgGroupPermissions' => [ + 'sysop' => [ 'delete', 'undelete' ], + 'user' => [ 'edit' ] + ], + ] + ] + ]; + } + + /** + * Attributes under manifest_version 2 + */ + public function testExtractAttributes() { + $processor = new ExtensionProcessor(); + // Load FooBar extension + $processor->extractInfo( $this->dir, [ 'name' => 'FooBar' ], 2 ); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'Baz', + 'attributes' => [ + // Loaded + 'FooBar' => [ + 'Plugins' => [ + 'ext.baz.foobar', + ], + ], + // Not loaded + 'FizzBuzz' => [ + 'MorePlugins' => [ + 'ext.baz.fizzbuzz', + ], + ], + ], + ], + 2 + ); + + $info = $processor->getExtractedInfo(); + $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); + $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); + $this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); + } + + /** + * Attributes under manifest_version 1 + */ + public function testAttributes1() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'FooBar', + 'FooBarPlugins' => [ + 'ext.baz.foobar', + ], + 'FizzBuzzMorePlugins' => [ + 'ext.baz.fizzbuzz', + ], + ], + 1 + ); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'FooBar2', + 'FizzBuzzMorePlugins' => [ + 'ext.bar.fizzbuzz', + ] + ], + 1 + ); + + $info = $processor->getExtractedInfo(); + $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); + $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); + $this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); + $this->assertSame( + [ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ], + $info['attributes']['FizzBuzzMorePlugins'] + ); + } + + public function testAttributes1_notarray() { + $processor = new ExtensionProcessor(); + $this->setExpectedException( + InvalidArgumentException::class, + "The value for 'FooBarPlugins' should be an array (from {$this->dir})" + ); + $processor->extractInfo( + $this->dir, + [ + 'FooBarPlugins' => 'ext.baz.foobar', + ] + self::$default, + 1 + ); + } + + public function testExtractPathBasedGlobal() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( + $this->dir, + [ + 'ParserTestFiles' => [ + 'tests/parserTests.txt', + 'tests/extraParserTests.txt', + ], + 'ServiceWiringFiles' => [ + 'includes/ServiceWiring.php' + ], + ] + self::$default, + 1 + ); + $globals = $processor->getExtractedInfo()['globals']; + $this->assertArrayHasKey( 'wgParserTestFiles', $globals ); + $this->assertSame( [ + "{$this->dirname}/tests/parserTests.txt", + "{$this->dirname}/tests/extraParserTests.txt" + ], $globals['wgParserTestFiles'] ); + $this->assertArrayHasKey( 'wgServiceWiringFiles', $globals ); + $this->assertSame( [ + "{$this->dirname}/includes/ServiceWiring.php" + ], $globals['wgServiceWiringFiles'] ); + } + + public function testGetRequirements() { + $info = self::$default + [ + 'requires' => [ + 'MediaWiki' => '>= 1.25.0', + 'platform' => [ + 'php' => '>= 5.5.9' + ], + 'extensions' => [ + 'Bar' => '*' + ] + ] + ]; + $processor = new ExtensionProcessor(); + $this->assertSame( + $info['requires'], + $processor->getRequirements( $info, false ) + ); + $this->assertSame( + [], + $processor->getRequirements( [], false ) + ); + } + + public function testGetDevRequirements() { + $info = self::$default + [ + 'dev-requires' => [ + 'MediaWiki' => '>= 1.31.0', + 'platform' => [ + 'ext-foo' => '*', + ], + 'skins' => [ + 'Baz' => '*', + ], + 'extensions' => [ + 'Biz' => '*', + ], + ], + ]; + $processor = new ExtensionProcessor(); + $this->assertSame( + $info['dev-requires'], + $processor->getRequirements( $info, true ) + ); + // Set some standard requirements, so we can test merging + $info['requires'] = [ + 'MediaWiki' => '>= 1.25.0', + 'platform' => [ + 'php' => '>= 5.5.9' + ], + 'extensions' => [ + 'Bar' => '*' + ] + ]; + $this->assertSame( + [ + 'MediaWiki' => '>= 1.25.0 >= 1.31.0', + 'platform' => [ + 'php' => '>= 5.5.9', + 'ext-foo' => '*', + ], + 'extensions' => [ + 'Bar' => '*', + 'Biz' => '*', + ], + 'skins' => [ + 'Baz' => '*', + ], + ], + $processor->getRequirements( $info, true ) + ); + + // If there's no dev-requires, it just returns requires + unset( $info['dev-requires'] ); + $this->assertSame( + $info['requires'], + $processor->getRequirements( $info, true ) + ); + } + + public function testGetExtraAutoloaderPaths() { + $processor = new ExtensionProcessor(); + $this->assertSame( + [ "{$this->dirname}/vendor/autoload.php" ], + $processor->getExtraAutoloaderPaths( $this->dirname, [ + 'load_composer_autoloader' => true, + ] ) + ); + } + + /** + * Verify that extension.schema.json is in sync with ExtensionProcessor + * + * @coversNothing + */ + public function testGlobalSettingsDocumentedInSchema() { + global $IP; + $globalSettings = TestingAccessWrapper::newFromClass( + ExtensionProcessor::class )->globalSettings; + + $version = ExtensionRegistry::MANIFEST_VERSION; + $schema = FormatJson::decode( + file_get_contents( "$IP/docs/extension.schema.v$version.json" ), + true + ); + $missing = []; + foreach ( $globalSettings as $global ) { + if ( !isset( $schema['properties'][$global] ) ) { + $missing[] = $global; + } + } + + $this->assertEquals( [], $missing, + "The following global settings are not documented in docs/extension.schema.json" ); + } +} + +/** + * Allow overriding the default value of $this->globals + * so we can test merging + */ +class MockExtensionProcessor extends ExtensionProcessor { + public function __construct( $globals = [] ) { + $this->globals = $globals + $this->globals; + } +} diff --git a/tests/phpunit/unit/includes/search/SearchIndexFieldTest.php b/tests/phpunit/unit/includes/search/SearchIndexFieldTest.php new file mode 100644 index 0000000000..a640c967cc --- /dev/null +++ b/tests/phpunit/unit/includes/search/SearchIndexFieldTest.php @@ -0,0 +1,56 @@ +getMockBuilder( SearchIndexFieldDefinition::class ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n1, $t1 ] ) + ->getMock(); + $field2 = + $this->getMockBuilder( SearchIndexFieldDefinition::class ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n2, $t2 ] ) + ->getMock(); + + if ( $result ) { + $this->assertNotFalse( $field1->merge( $field2 ) ); + } else { + $this->assertFalse( $field1->merge( $field2 ) ); + } + + $field1->setFlag( 0xFF ); + $this->assertFalse( $field1->merge( $field2 ) ); + + $field1->setMergeCallback( + function ( $a, $b ) { + return "test"; + } + ); + $this->assertEquals( "test", $field1->merge( $field2 ) ); + } + +} diff --git a/tests/phpunit/unit/includes/session/MetadataMergeExceptionTest.php b/tests/phpunit/unit/includes/session/MetadataMergeExceptionTest.php new file mode 100644 index 0000000000..707adfe5b0 --- /dev/null +++ b/tests/phpunit/unit/includes/session/MetadataMergeExceptionTest.php @@ -0,0 +1,28 @@ + 'bar' ]; + + $ex = new MetadataMergeException(); + $this->assertInstanceOf( \UnexpectedValueException::class, $ex ); + $this->assertSame( [], $ex->getContext() ); + + $ex2 = new MetadataMergeException( 'Message', 42, $ex, $data ); + $this->assertSame( 'Message', $ex2->getMessage() ); + $this->assertSame( 42, $ex2->getCode() ); + $this->assertSame( $ex, $ex2->getPrevious() ); + $this->assertSame( $data, $ex2->getContext() ); + + $ex->setContext( $data ); + $this->assertSame( $data, $ex->getContext() ); + } + +} diff --git a/tests/phpunit/unit/includes/session/SessionIdTest.php b/tests/phpunit/unit/includes/session/SessionIdTest.php new file mode 100644 index 0000000000..3c7f8cbfdf --- /dev/null +++ b/tests/phpunit/unit/includes/session/SessionIdTest.php @@ -0,0 +1,20 @@ +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/unit/includes/site/CachingSiteStoreTest.php b/tests/phpunit/unit/includes/site/CachingSiteStoreTest.php new file mode 100644 index 0000000000..92ed1f53e8 --- /dev/null +++ b/tests/phpunit/unit/includes/site/CachingSiteStoreTest.php @@ -0,0 +1,167 @@ + + */ +class CachingSiteStoreTest extends \MediaWikiUnitTestCase { + + /** + * @covers CachingSiteStore::getSites + */ + public function testGetSites() { + $testSites = TestSites::getSites(); + + $store = new CachingSiteStore( + $this->getHashSiteStore( $testSites ), + ObjectCache::getLocalClusterInstance() + ); + + $sites = $store->getSites(); + + $this->assertInstanceOf( SiteList::class, $sites ); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertInstanceOf( Site::class, $site ); + } + + foreach ( $testSites as $site ) { + if ( $site->getGlobalId() !== null ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @covers CachingSiteStore::saveSites + */ + public function testSaveSites() { + $store = new CachingSiteStore( + new HashSiteStore(), ObjectCache::getLocalClusterInstance() + ); + + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'ertrywuutr' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'sdfhxujgkfpth' ); + $site->setLanguageCode( 'nl' ); + $sites[] = $site; + + $this->assertTrue( $store->saveSites( $sites ) ); + + $site = $store->getSite( 'ertrywuutr' ); + $this->assertInstanceOf( Site::class, $site ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + + $site = $store->getSite( 'sdfhxujgkfpth' ); + $this->assertInstanceOf( Site::class, $site ); + $this->assertEquals( 'nl', $site->getLanguageCode() ); + } + + /** + * @covers CachingSiteStore::reset + */ + public function testReset() { + $dbSiteStore = $this->getMockBuilder( SiteStore::class ) + ->disableOriginalConstructor() + ->getMock(); + + $dbSiteStore->expects( $this->any() ) + ->method( 'getSite' ) + ->will( $this->returnValue( $this->getTestSite() ) ); + + $dbSiteStore->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnCallback( function () { + $siteList = new SiteList(); + $siteList->setSite( $this->getTestSite() ); + + return $siteList; + } ) ); + + $store = new CachingSiteStore( $dbSiteStore, ObjectCache::getLocalClusterInstance() ); + + // initialize internal cache + $this->assertGreaterThan( 0, $store->getSites()->count(), 'count sites' ); + + $store->getSite( 'enwiki' )->setLanguageCode( 'en-ca' ); + + // sanity check: $store should have the new language code for 'enwiki' + $this->assertEquals( 'en-ca', $store->getSite( 'enwiki' )->getLanguageCode(), 'sanity check' ); + + // purge cache + $store->reset(); + + // the internal cache of $store should be updated, and now pulling + // the site from the 'fallback' DBSiteStore with the original language code. + $this->assertEquals( 'en', $store->getSite( 'enwiki' )->getLanguageCode(), 'reset' ); + } + + public function getTestSite() { + $enwiki = new MediaWikiSite(); + $enwiki->setGlobalId( 'enwiki' ); + $enwiki->setLanguageCode( 'en' ); + + return $enwiki; + } + + /** + * @covers CachingSiteStore::clear + */ + public function testClear() { + $store = new CachingSiteStore( + new HashSiteStore(), ObjectCache::getLocalClusterInstance() + ); + $this->assertTrue( $store->clear() ); + + $site = $store->getSite( 'enwiki' ); + $this->assertNull( $site ); + + $sites = $store->getSites(); + $this->assertEquals( 0, $sites->count() ); + } + + /** + * @param Site[] $sites + * + * @return SiteStore + */ + private function getHashSiteStore( array $sites ) { + $siteStore = new HashSiteStore(); + $siteStore->saveSites( $sites ); + + return $siteStore; + } + +} diff --git a/tests/phpunit/unit/includes/site/HashSiteStoreTest.php b/tests/phpunit/unit/includes/site/HashSiteStoreTest.php new file mode 100644 index 0000000000..8b0d4e080b --- /dev/null +++ b/tests/phpunit/unit/includes/site/HashSiteStoreTest.php @@ -0,0 +1,105 @@ + + */ +class HashSiteStoreTest extends \MediaWikiUnitTestCase { + + /** + * @covers HashSiteStore::getSites + */ + public function testGetSites() { + $expectedSites = []; + + foreach ( TestSites::getSites() as $testSite ) { + $siteId = $testSite->getGlobalId(); + $expectedSites[$siteId] = $testSite; + } + + $siteStore = new HashSiteStore( $expectedSites ); + + $this->assertEquals( new SiteList( $expectedSites ), $siteStore->getSites() ); + } + + /** + * @covers HashSiteStore::saveSite + * @covers HashSiteStore::getSite + */ + public function testSaveSite() { + $store = new HashSiteStore(); + + $site = new Site(); + $site->setGlobalId( 'dewiki' ); + + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + + $store->saveSite( $site ); + + $this->assertCount( 1, $store->getSites(), 'Store has 1 sites' ); + $this->assertEquals( $site, $store->getSite( 'dewiki' ), 'Store has dewiki' ); + } + + /** + * @covers HashSiteStore::saveSites + */ + public function testSaveSites() { + $store = new HashSiteStore(); + + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'enwiki' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'eswiki' ); + $site->setLanguageCode( 'es' ); + $sites[] = $site; + + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + + $store->saveSites( $sites ); + + $this->assertCount( 2, $store->getSites(), 'Store has 2 sites' ); + $this->assertTrue( $store->getSites()->hasSite( 'enwiki' ), 'Store has enwiki' ); + $this->assertTrue( $store->getSites()->hasSite( 'eswiki' ), 'Store has eswiki' ); + } + + /** + * @covers HashSiteStore::clear + */ + public function testClear() { + $store = new HashSiteStore(); + + $site = new Site(); + $site->setGlobalId( 'arwiki' ); + $store->saveSite( $site ); + + $this->assertCount( 1, $store->getSites(), '1 site in store' ); + + $store->clear(); + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + } +} diff --git a/tests/phpunit/unit/includes/skins/SkinFactoryTest.php b/tests/phpunit/unit/includes/skins/SkinFactoryTest.php new file mode 100644 index 0000000000..8443c8d1f6 --- /dev/null +++ b/tests/phpunit/unit/includes/skins/SkinFactoryTest.php @@ -0,0 +1,82 @@ +register( 'fallback', 'Fallback', function () { + return new SkinFallback(); + } ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithNoBuilders() { + $factory = new SkinFactory(); + $this->setExpectedException( SkinException::class ); + $factory->makeSkin( 'nobuilderregistered' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithInvalidCallback() { + $factory = new SkinFactory(); + $factory->register( 'unittest', 'Unittest', function () { + return true; // Not a Skin object + } ); + $this->setExpectedException( UnexpectedValueException::class ); + $factory->makeSkin( 'unittest' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithValidCallback() { + $factory = new SkinFactory(); + $factory->register( 'testfallback', 'TestFallback', function () { + return new SkinFallback(); + } ); + + $skin = $factory->makeSkin( 'testfallback' ); + $this->assertInstanceOf( Skin::class, $skin ); + $this->assertInstanceOf( SkinFallback::class, $skin ); + $this->assertEquals( 'fallback', $skin->getSkinName() ); + } + + /** + * @covers Skin::__construct + * @covers Skin::getSkinName + */ + public function testGetSkinName() { + $skin = new SkinFallback(); + $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' ); + $skin = new SkinFallback( 'testname' ); + $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' ); + } + + /** + * @covers SkinFactory::getSkinNames + */ + public function testGetSkinNames() { + $factory = new SkinFactory(); + // A fake callback we can use that will never be called + $callback = function () { + // NOP + }; + $factory->register( 'skin1', 'Skin1', $callback ); + $factory->register( 'skin2', 'Skin2', $callback ); + $names = $factory->getSkinNames(); + $this->assertArrayHasKey( 'skin1', $names ); + $this->assertArrayHasKey( 'skin2', $names ); + $this->assertEquals( 'Skin1', $names['skin1'] ); + $this->assertEquals( 'Skin2', $names['skin2'] ); + } +} diff --git a/tests/phpunit/unit/includes/title/ForeignTitleTest.php b/tests/phpunit/unit/includes/title/ForeignTitleTest.php new file mode 100644 index 0000000000..ec093cf83b --- /dev/null +++ b/tests/phpunit/unit/includes/title/ForeignTitleTest.php @@ -0,0 +1,103 @@ +assertEquals( true, $title->isNamespaceIdKnown() ); + $this->assertEquals( $expectedId, $title->getNamespaceId() ); + $this->assertEquals( $expectedName, $title->getNamespaceName() ); + $this->assertEquals( $expectedText, $title->getText() ); + } + + public function testUnknownNamespaceCheck() { + $title = new ForeignTitle( null, 'this', 'that' ); + + $this->assertEquals( false, $title->isNamespaceIdKnown() ); + $this->assertEquals( 'this', $title->getNamespaceName() ); + $this->assertEquals( 'that', $title->getText() ); + } + + public function testUnknownNamespaceError() { + $this->setExpectedException( MWException::class ); + $title = new ForeignTitle( null, 'this', 'that' ); + $title->getNamespaceId(); + } + + public function fullTextProvider() { + return [ + [ + new ForeignTitle( 20, 'Contributor', 'JohnDoe' ), + 'Contributor:JohnDoe' + ], + [ + new ForeignTitle( '1', 'Discussion', 'Capital' ), + 'Discussion:Capital' + ], + [ + new ForeignTitle( 0, '', 'MainNamespace' ), + 'MainNamespace' + ], + [ + new ForeignTitle( 4, 'Some ns', 'Article title with spaces' ), + 'Some_ns:Article_title_with_spaces' + ], + ]; + } + + /** + * @dataProvider fullTextProvider + */ + public function testFullText( ForeignTitle $title, $fullText ) { + $this->assertEquals( $fullText, $title->getFullText() ); + } +} diff --git a/tests/phpunit/unit/includes/title/NamespaceAwareForeignTitleFactoryTest.php b/tests/phpunit/unit/includes/title/NamespaceAwareForeignTitleFactoryTest.php new file mode 100644 index 0000000000..d77797314b --- /dev/null +++ b/tests/phpunit/unit/includes/title/NamespaceAwareForeignTitleFactoryTest.php @@ -0,0 +1,101 @@ + '', 1 => 'Talk', 100 => 'Portal', 9000 => 'Bogus' + ]; + + $factory = new NamespaceAwareForeignTitleFactory( $foreignNamespaces ); + $testTitle = $factory->createForeignTitle( $title, $ns ); + + $this->assertEquals( $testTitle->isNamespaceIdKnown(), + $foreignTitle->isNamespaceIdKnown() ); + + if ( + $testTitle->isNamespaceIdKnown() && + $foreignTitle->isNamespaceIdKnown() + ) { + $this->assertEquals( $testTitle->getNamespaceId(), + $foreignTitle->getNamespaceId() ); + } + + $this->assertEquals( $testTitle->getNamespaceName(), + $foreignTitle->getNamespaceName() ); + $this->assertEquals( $testTitle->getText(), $foreignTitle->getText() ); + } +} diff --git a/tests/phpunit/unit/includes/title/TitleValueTest.php b/tests/phpunit/unit/includes/title/TitleValueTest.php new file mode 100644 index 0000000000..cd67a9329d --- /dev/null +++ b/tests/phpunit/unit/includes/title/TitleValueTest.php @@ -0,0 +1,149 @@ +assertEquals( $ns, $title->getNamespace() ); + $this->assertTrue( $title->inNamespace( $ns ) ); + $this->assertEquals( $text, $title->getText() ); + $this->assertEquals( $fragment, $title->getFragment() ); + $this->assertEquals( $hasFragment, $title->hasFragment() ); + $this->assertEquals( $interwiki, $title->getInterwiki() ); + $this->assertEquals( $hasInterwiki, $title->isExternal() ); + } + + public function badConstructorProvider() { + return [ + [ 'foo', 'title', 'fragment', '' ], + [ null, 'title', 'fragment', '' ], + [ 2.3, 'title', 'fragment', '' ], + + [ NS_MAIN, 5, 'fragment', '' ], + [ NS_MAIN, null, 'fragment', '' ], + [ NS_USER, '', 'fragment', '' ], + [ NS_MAIN, 'foo bar', '', '' ], + [ NS_MAIN, 'bar_', '', '' ], + [ NS_MAIN, '_foo', '', '' ], + [ NS_MAIN, ' eek ', '', '' ], + + [ NS_MAIN, 'title', 5, '' ], + [ NS_MAIN, 'title', null, '' ], + [ NS_MAIN, 'title', [], '' ], + + [ NS_MAIN, 'title', '', 5 ], + [ NS_MAIN, 'title', null, 5 ], + [ NS_MAIN, 'title', [], 5 ], + ]; + } + + /** + * @dataProvider badConstructorProvider + */ + public function testConstructionErrors( $ns, $text, $fragment, $interwiki ) { + $this->setExpectedException( InvalidArgumentException::class ); + new TitleValue( $ns, $text, $fragment, $interwiki ); + } + + public function fragmentTitleProvider() { + return [ + [ new TitleValue( NS_MAIN, 'Test' ), 'foo' ], + [ new TitleValue( NS_TALK, 'Test', 'foo' ), '' ], + [ new TitleValue( NS_CATEGORY, 'Test', 'foo' ), 'bar' ], + ]; + } + + /** + * @dataProvider fragmentTitleProvider + */ + public function testCreateFragmentTitle( TitleValue $title, $fragment ) { + $fragmentTitle = $title->createFragmentTarget( $fragment ); + + $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() ); + $this->assertEquals( $title->getText(), $fragmentTitle->getText() ); + $this->assertEquals( $fragment, $fragmentTitle->getFragment() ); + } + + public function getTextProvider() { + return [ + [ 'Foo', 'Foo' ], + [ 'Foo_Bar', 'Foo Bar' ], + ]; + } + + /** + * @dataProvider getTextProvider + */ + public function testGetText( $dbkey, $text ) { + $title = new TitleValue( NS_MAIN, $dbkey ); + + $this->assertEquals( $text, $title->getText() ); + } + + public function provideTestToString() { + yield [ + new TitleValue( 0, 'Foo' ), + '0:Foo' + ]; + yield [ + new TitleValue( 1, 'Bar_Baz' ), + '1:Bar_Baz' + ]; + yield [ + new TitleValue( 9, 'JoJo', 'Frag' ), + '9:JoJo#Frag' + ]; + yield [ + new TitleValue( 200, 'tea', 'Fragment', 'wikicode' ), + 'wikicode:200:tea#Fragment' + ]; + } + + /** + * @dataProvider provideTestToString + */ + public function testToString( TitleValue $value, $expected ) { + $this->assertSame( + $expected, + $value->__toString() + ); + } +} diff --git a/tests/phpunit/unit/includes/user/UserArrayFromResultTest.php b/tests/phpunit/unit/includes/user/UserArrayFromResultTest.php new file mode 100644 index 0000000000..0b2ce17d68 --- /dev/null +++ b/tests/phpunit/unit/includes/user/UserArrayFromResultTest.php @@ -0,0 +1,110 @@ +getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithUsername( $username = 'fooUser' ) { + $row = new stdClass(); + $row->user_name = $username; + return $row; + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = new UserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $username = 'addshore'; + $row = $this->getRowWithUsername( $username ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = new UserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( User::class, $object->current ); + $this->assertEquals( $username, $object->current->mName ); + } + + public static function provideNumberOfRows() { + return [ + [ 0 ], + [ 1 ], + [ 122 ], + ]; + } + + /** + * @dataProvider provideNumberOfRows + * @covers UserArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = new UserArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithUsername(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers UserArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $username = 'addshore'; + $userRow = $this->getRowWithUsername( $username ); + $object = new UserArrayFromResult( $this->getMockResultWrapper( $userRow ) ); + $this->assertInstanceOf( User::class, $object->current() ); + $this->assertEquals( $username, $object->current()->mName ); + } + + public function provideTestValid() { + return [ + [ $this->getRowWithUsername(), true ], + [ false, false ], + ]; + } + + /** + * @dataProvider provideTestValid + * @covers UserArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = new UserArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + // @todo unit test for key() + // @todo unit test for next() + // @todo unit test for rewind() +} diff --git a/tests/phpunit/unit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php b/tests/phpunit/unit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php new file mode 100644 index 0000000000..556f518e54 --- /dev/null +++ b/tests/phpunit/unit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php @@ -0,0 +1,250 @@ +getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'addWatch' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->addWatch( + new UserIdentityValue( 1, 'MockUser', 0 ), new TitleValue( 0, 'Foo' ) ); + } + + public function testAddWatchBatchForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'addWatchBatchForUser' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->addWatchBatchForUser( new UserIdentityValue( 1, 'MockUser', 0 ), [] ); + } + + public function testRemoveWatch() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'removeWatch' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->removeWatch( + new UserIdentityValue( 1, 'MockUser', 0 ), new TitleValue( 0, 'Foo' ) ); + } + + public function testSetNotificationTimestampsForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'setNotificationTimestampsForUser' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->setNotificationTimestampsForUser( + new UserIdentityValue( 1, 'MockUser', 0 ), + 'timestamp', + [] + ); + } + + public function testUpdateNotificationTimestamp() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'updateNotificationTimestamp' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->updateNotificationTimestamp( + new UserIdentityValue( 1, 'MockUser', 0 ), + new TitleValue( 0, 'Foo' ), + 'timestamp' + ); + } + + public function testResetNotificationTimestamp() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->never() )->method( 'resetNotificationTimestamp' ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->resetNotificationTimestamp( + new UserIdentityValue( 1, 'MockUser', 0 ), + new TitleValue( 0, 'Foo' ) + ); + } + + public function testCountWatchedItems() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'countWatchedItems' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchedItems( + new UserIdentityValue( 1, 'MockUser', 0 ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountWatchers() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'countWatchers' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchers( + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountVisitingWatchers() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchers' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countVisitingWatchers( + new TitleValue( 0, 'Foo' ), + 9 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountWatchersMultiple() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchersMultiple' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countWatchersMultiple( + [ new TitleValue( 0, 'Foo' ) ], + [] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountVisitingWatchersMultiple() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countVisitingWatchersMultiple' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countVisitingWatchersMultiple( + [ [ new TitleValue( 0, 'Foo' ), 99 ] ], + 11 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetWatchedItem() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'getWatchedItem' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getWatchedItem( + new UserIdentityValue( 1, 'MockUser', 0 ), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testLoadWatchedItem() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'loadWatchedItem' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->loadWatchedItem( + new UserIdentityValue( 1, 'MockUser', 0 ), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetWatchedItemsForUser() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'getWatchedItemsForUser' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getWatchedItemsForUser( + new UserIdentityValue( 1, 'MockUser', 0 ), + [] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testIsWatched() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() )->method( 'isWatched' )->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->isWatched( + new UserIdentityValue( 1, 'MockUser', 0 ), + new TitleValue( 0, 'Foo' ) + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testGetNotificationTimestampsBatch() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'getNotificationTimestampsBatch' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->getNotificationTimestampsBatch( + new UserIdentityValue( 1, 'MockUser', 0 ), + [ new TitleValue( 0, 'Foo' ) ] + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testCountUnreadNotifications() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $innerService->expects( $this->once() ) + ->method( 'countUnreadNotifications' ) + ->willReturn( __METHOD__ ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $return = $noWriteService->countUnreadNotifications( + new UserIdentityValue( 1, 'MockUser', 0 ), + 88 + ); + $this->assertEquals( __METHOD__, $return ); + } + + public function testDuplicateAllAssociatedEntries() { + /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */ + $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class ); + $noWriteService = new NoWriteWatchedItemStore( $innerService ); + + $this->setExpectedException( DBReadOnlyError::class ); + $noWriteService->duplicateAllAssociatedEntries( + new TitleValue( 0, 'Foo' ), + new TitleValue( 0, 'Bar' ) + ); + } + +} diff --git a/tests/qunit/data/generateJqueryMsgData.php b/tests/qunit/data/generateJqueryMsgData.php index a85e41a012..30d993e155 100644 --- a/tests/qunit/data/generateJqueryMsgData.php +++ b/tests/qunit/data/generateJqueryMsgData.php @@ -84,7 +84,7 @@ class GenerateJqueryMsgData extends Maintenance { public function __construct() { parent::__construct(); - $this->mDescription = 'Create a specification for message parsing ini JSON format'; + $this->addDescription( 'Create a specification for message parsing ini JSON format' ); // add any other options here } diff --git a/tests/qunit/data/load.mock.php b/tests/qunit/data/load.mock.php index 9f57190ea4..1525f0440f 100644 --- a/tests/qunit/data/load.mock.php +++ b/tests/qunit/data/load.mock.php @@ -18,9 +18,7 @@ * http://www.gnu.org/copyleft/gpl.html * * @file - * @package MediaWiki * @author Lupo - * @since 1.20 */ // This file doesn't run as part of MediaWiki diff --git a/tests/qunit/data/styleTest.css.php b/tests/qunit/data/styleTest.css.php index db96fd5325..5d229a3ab2 100644 --- a/tests/qunit/data/styleTest.css.php +++ b/tests/qunit/data/styleTest.css.php @@ -18,9 +18,6 @@ * http://www.gnu.org/copyleft/gpl.html * * @file - * @package MediaWiki - * @author Timo Tijhof - * @since 1.20 */ // This file doesn't run as part of MediaWiki diff --git a/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js b/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js index 0b3e809eaa..54f6b4e680 100644 --- a/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js @@ -22,17 +22,17 @@ // On collapse... $collapsible.on( 'beforeCollapse.mw-collapsible', function () { - assert.assertTrue( $content.is( ':visible' ), 'first beforeCollapseExpand: content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'first beforeCollapseExpand: content is visible' ); } ); $collapsible.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $content.is( ':hidden' ), 'first afterCollapseExpand: content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'first afterCollapseExpand: content is hidden' ); // On expand... $collapsible.on( 'beforeExpand.mw-collapsible', function () { - assert.assertTrue( $content.is( ':hidden' ), 'second beforeCollapseExpand: content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'second beforeCollapseExpand: content is hidden' ); } ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $content.is( ':visible' ), 'second afterCollapseExpand: content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'second afterCollapseExpand: content is visible' ); } ); // ...expanding happens here @@ -53,13 +53,13 @@ assert.strictEqual( $content.length, 1, 'content is present' ); assert.strictEqual( $content.find( $toggle ).length, 0, 'toggle is not a descendant of content' ); - assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' ); $collapsible.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' ); } ); $toggle.trigger( 'click' ); @@ -76,22 +76,22 @@ '
    ' + loremIpsum + '' + loremIpsum + '
    ' + '' ), - $headerRow = $collapsible.find( 'tr:first' ), - $contentRow = $collapsible.find( 'tr:last' ), - $toggle = $headerRow.find( 'td:last .mw-collapsible-toggle' ); + $headerRow = $collapsible.find( 'tr' ).first(), + $contentRow = $collapsible.find( 'tr' ).last(), + $toggle = $headerRow.find( 'td' ).last().find( '.mw-collapsible-toggle' ); assert.strictEqual( $toggle.length, 1, 'toggle is added to last cell of first row' ); - assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' ); - assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' ); + assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' ); + assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' ); $collapsible.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $headerRow.is( ':visible' ), 'after collapsing: headerRow is still visible' ); - assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' ); + assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after collapsing: headerRow is still visible' ); + assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is still visible' ); - assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' ); + assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is still visible' ); + assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' ); } ); $toggle.trigger( 'click' ); @@ -102,25 +102,25 @@ function tableWithCaptionTest( $collapsible, test, assert ) { var $caption = $collapsible.find( 'caption' ), - $headerRow = $collapsible.find( 'tr:first' ), - $contentRow = $collapsible.find( 'tr:last' ), + $headerRow = $collapsible.find( 'tr' ).first(), + $contentRow = $collapsible.find( 'tr' ).last(), $toggle = $caption.find( '.mw-collapsible-toggle' ); assert.strictEqual( $toggle.length, 1, 'toggle is added to the end of the caption' ); - assert.assertTrue( $caption.is( ':visible' ), 'caption is visible' ); - assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' ); - assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' ); + assert.assertTrue( $caption.css( 'display' ) !== 'none', 'caption is visible' ); + assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' ); + assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' ); $collapsible.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $caption.is( ':visible' ), 'after collapsing: caption is still visible' ); - assert.assertTrue( $headerRow.is( ':hidden' ), 'after collapsing: headerRow is hidden' ); - assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' ); + assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after collapsing: caption is still visible' ); + assert.assertTrue( $headerRow.css( 'display' ) === 'none', 'after collapsing: headerRow is hidden' ); + assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $caption.is( ':visible' ), 'after expanding: caption is still visible' ); - assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is visible' ); - assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' ); + assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after expanding: caption is still visible' ); + assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is visible' ); + assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' ); } ); $toggle.trigger( 'click' ); @@ -159,21 +159,21 @@ '' ), $toggleItem = $collapsible.find( 'li.mw-collapsible-toggle-li:first-child' ), - $contentItem = $collapsible.find( 'li:last' ), + $contentItem = $collapsible.find( 'li' ).last(), $toggle = $toggleItem.find( '.mw-collapsible-toggle' ); assert.strictEqual( $toggle.length, 1, 'toggle is present, added inside new zeroth list item' ); - assert.assertTrue( $toggleItem.is( ':visible' ), 'toggleItem is visible' ); - assert.assertTrue( $contentItem.is( ':visible' ), 'contentItem is visible' ); + assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'toggleItem is visible' ); + assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'contentItem is visible' ); $collapsible.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $toggleItem.is( ':visible' ), 'after collapsing: toggleItem is still visible' ); - assert.assertTrue( $contentItem.is( ':hidden' ), 'after collapsing: contentItem is hidden' ); + assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after collapsing: toggleItem is still visible' ); + assert.assertTrue( $contentItem.css( 'display' ) === 'none', 'after collapsing: contentItem is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $toggleItem.is( ':visible' ), 'after expanding: toggleItem is still visible' ); - assert.assertTrue( $contentItem.is( ':visible' ), 'after expanding: contentItem is visible' ); + assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after expanding: toggleItem is still visible' ); + assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'after expanding: contentItem is visible' ); } ); $toggle.trigger( 'click' ); @@ -197,11 +197,11 @@ ), $content = $collapsible.find( '.mw-collapsible-content' ); - assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); - assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' ); } ); QUnit.test( 'mw-made-collapsible data added', function ( assert ) { @@ -217,6 +217,7 @@ '

    ' + loremIpsum + '
    ' ); + // eslint-disable-next-line no-jquery/no-class-state assert.assertTrue( $collapsible.hasClass( 'mw-collapsible' ), 'mw-collapsible class present' ); } ); @@ -226,6 +227,7 @@ { collapsed: true } ); + // eslint-disable-next-line no-jquery/no-class-state assert.assertTrue( $collapsible.hasClass( 'mw-collapsed' ), 'mw-collapsed class present' ); } ); @@ -236,10 +238,10 @@ $content = $collapsible.find( '.mw-collapsible-content' ); // Synchronous - mw-collapsed should cause instantHide: true to be used on initial collapsing - assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' ); } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); @@ -253,10 +255,10 @@ $content = $collapsible.find( '.mw-collapsible-content' ); // Synchronous - collapsed: true should cause instantHide: true to be used on initial collapsing - assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' ); $collapsible.on( 'afterExpand.mw-collapsible', function () { - assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' ); } ); $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); @@ -276,10 +278,10 @@ $content = $collapsible.find( '.mw-collapsible-content' ); $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); - assert.assertTrue( $content.is( ':visible' ), 'click event on link inside toggle passes through (content not toggled)' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'click event on link inside toggle passes through (content not toggled)' ); $collapsible.find( '.mw-collapsible-toggle b' ).trigger( 'click' ); - assert.assertTrue( $content.is( ':hidden' ), 'click event on non-link inside toggle toggles content' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on non-link inside toggle toggles content' ); } ); QUnit.test( 'click on non-link inside toggler counts as trigger', function ( assert ) { @@ -295,7 +297,7 @@ $content = $collapsible.find( '.mw-collapsible-content' ); $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); - assert.assertTrue( $content.is( ':hidden' ), 'click event on link (with no href) inside toggle toggles content' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on link (with no href) inside toggle toggles content' ); } ); QUnit.test( 'collapse/expand text (data-collapsetext, data-expandtext)', function ( assert ) { @@ -366,10 +368,10 @@ .appendTo( '#qunit-fixture' ).makeCollapsible(), $content = $clone.find( '.mw-collapsible-content' ); - assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' ); $clone.on( 'afterCollapse.mw-collapsible', function () { - assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' ); } ); $clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); @@ -388,9 +390,13 @@ .appendTo( '#qunit-fixture' ).makeCollapsible(); $collapsible1.on( 'afterCollapse.mw-collapsible', function () { + // eslint-disable-next-line no-jquery/no-class-state assert.assertTrue( $collapsible1.hasClass( 'mw-collapsed' ), 'after collapsing: parent is collapsed' ); + // eslint-disable-next-line no-jquery/no-class-state assert.assertFalse( $collapsible2.hasClass( 'mw-collapsed' ), 'after collapsing: child is not collapsed' ); + // eslint-disable-next-line no-jquery/no-class-state assert.assertTrue( $collapsible1.find( '> .mw-collapsible-toggle' ).hasClass( 'mw-collapsible-toggle-collapsed' ) ); + // eslint-disable-next-line no-jquery/no-class-state assert.assertFalse( $collapsible2.find( '> .mw-collapsible-toggle' ).hasClass( 'mw-collapsible-toggle-collapsed' ) ); } ).find( '> .mw-collapsible-toggle a' ).trigger( 'click' ); } ); diff --git a/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js b/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js index 4731b32aa4..506b25bbef 100644 --- a/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js @@ -74,9 +74,9 @@ } text = [ - [ 'Mars', true, 'mars', 'Simple text' ], - [ 'Mẘas', true, 'mẘas', 'Non ascii character' ], - [ 'A sentence', true, 'a sentence', 'A sentence with space chars' ] + [ 'Mars', true, 'Mars', 'Simple text' ], + [ 'Mẘas', true, 'Mẘas', 'Non ascii character' ], + [ 'A sentence', true, 'A sentence', 'A sentence with space chars' ] ]; parserTest( 'Textual keys', 'text', text ); diff --git a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js index bd6ee166a0..476b5058b0 100644 --- a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js @@ -64,21 +64,43 @@ [ 'Günther' ], [ 'Peter' ], [ 'Björn' ], + [ 'ä' ], + [ 'z' ], [ 'Bjorn' ], + [ 'BjÖrn' ], + [ 'apfel' ], [ 'Apfel' ], [ 'Äpfel' ], [ 'Strasse' ], [ 'Sträßschen' ] ], - umlautWordsSorted = [ + umlautWordsSortedEn = [ + [ 'ä' ], [ 'Äpfel' ], + [ 'apfel' ], [ 'Apfel' ], [ 'Björn' ], + [ 'BjÖrn' ], [ 'Bjorn' ], [ 'Günther' ], [ 'Peter' ], [ 'Sträßschen' ], - [ 'Strasse' ] + [ 'Strasse' ], + [ 'z' ] + ], + umlautWordsSortedSv = [ + [ 'apfel' ], + [ 'Apfel' ], + [ 'Bjorn' ], + [ 'Björn' ], + [ 'BjÖrn' ], + [ 'Günther' ], + [ 'Peter' ], + [ 'Strasse' ], + [ 'Sträßschen' ], + [ 'z' ], + [ 'ä' ], // ä sorts after z in Swedish + [ 'Äpfel' ] ], // Data set "digraph" @@ -369,7 +391,7 @@ planetsAscName, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -379,7 +401,7 @@ planetsAscName, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -389,9 +411,9 @@ planetsAscName, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -401,7 +423,7 @@ reversed( planetsAscName ), function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' ); } ); tableTest( @@ -411,7 +433,7 @@ planetsAscRadius, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); } ); tableTest( @@ -421,7 +443,7 @@ reversed( planetsAscRadius ), function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ).trigger( 'click' ); } ); tableTest( @@ -483,7 +505,7 @@ $table.tablesorter( { sortList: [ { 0: 'asc' }, { 1: 'asc' } ] } ); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -496,12 +518,12 @@ $table.tablesorter( { sortList: [ { 0: 'desc' }, { 1: 'desc' } ] } ); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); // Pretend to click while pressing the multi-sort key event = $.Event( 'click' ); event[ $table.data( 'tablesorter' ).config.sortMultiSortKey ] = true; - $table.find( '.headerSort:eq(1)' ).trigger( event ); + $table.find( '.headerSort' ).eq( 1 ).trigger( event ); } ); QUnit.test( 'Reset sorting making table appear unsorted', function ( assert ) { @@ -541,11 +563,12 @@ [ aaa1, aab5, abc3, bbc2, caa4 ], function ( $table ) { // Make colspanned header for test - $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); - $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( 'Sorting with colspanned headers: sort spanned column twice', @@ -554,12 +577,13 @@ [ caa4, bbc2, abc3, aab5, aaa1 ], function ( $table ) { // Make colspanned header for test - $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); - $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr' ).eq( 0 ).find( 'th' ).eq( 0 ).attr( 'colspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( 'Sorting with colspanned headers: subsequent column', @@ -568,11 +592,12 @@ [ aaa1, bbc2, abc3, caa4, aab5 ], function ( $table ) { // Make colspanned header for test - $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); - $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); } ); tableTest( 'Sorting with colspanned headers: sort subsequent column twice', @@ -581,22 +606,23 @@ [ aab5, caa4, abc3, bbc2, aaa1 ], function ( $table ) { // Make colspanned header for test - $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); - $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 1 ).remove(); + $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); } ); QUnit.test( 'Basic planet table: one unsortable column', function ( assert ) { var $table = tableCreate( header, planets ), $cell; - $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' ); + $table.find( 'tr > th' ).eq( 0 ).addClass( 'unsortable' ); $table.tablesorter(); - $table.find( 'tr:eq(0) > th:eq(0)' ).trigger( 'click' ); + $table.find( 'tr > th' ).eq( 0 ).trigger( 'click' ); assert.deepEqual( tableExtract( $table ), @@ -604,10 +630,11 @@ 'table not sorted' ); - $cell = $table.find( 'tr:eq(0) > th:eq(0)' ); - $table.find( 'tr:eq(0) > th:eq(1)' ).trigger( 'click' ); + $cell = $table.find( 'tr > th' ).eq( 0 ); + $table.find( 'tr > th' ).eq( 1 ).trigger( 'click' ); assert.strictEqual( + // eslint-disable-next-line no-jquery/no-class-state $cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ), false, 'after sort: no class headerSortUp or headerSortDown' @@ -646,7 +673,7 @@ mw.config.set( 'wgPageContentLanguage', 'de' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -673,7 +700,7 @@ mw.config.set( 'wgDefaultDateFormat', 'mdy' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -684,7 +711,7 @@ ipv4Sorted, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -694,7 +721,7 @@ reversed( ipv4Sorted ), function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' ); } ); @@ -702,7 +729,7 @@ 'Accented Characters with custom collation', [ 'Name' ], umlautWords, - umlautWordsSorted, + umlautWordsSortedEn, function ( $table ) { mw.config.set( 'tableSorterCollation', { ä: 'ae', @@ -712,6 +739,20 @@ } ); $table.tablesorter(); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); + } + ); + + tableTest( + 'Accented Characters Swedish locale', + [ 'Name' ], + umlautWords, + umlautWordsSortedSv, + function ( $table ) { + mw.config.set( 'wgPageContentLanguage', 'sv' ); + + $table.tablesorter(); + // eslint-disable-next-line no-jquery/no-sizzle $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); } ); @@ -728,7 +769,7 @@ } ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -737,15 +778,16 @@ // Modify the table to have a multiple-row-spanning cell: // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. - $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove(); + $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove(); // - Set rowspan for 2nd cell of 3rd row to 3. // This covers the removed cell in the 4th and 5th row. - $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' ); $table.tablesorter(); assert.strictEqual( - $table.find( 'tr:eq(2) td:eq(1)' ).prop( 'rowSpan' ), + $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).prop( 'rowSpan' ), 3, 'Rowspan not exploded' ); @@ -769,13 +811,14 @@ function ( $table ) { // Modify the table to have a multiple-row-spanning cell: // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. - $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove(); + $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove(); // - Set rowspan for 2nd cell of 3rd row to 3. // This covers the removed cell in the 4th and 5th row. - $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); tableTest( @@ -786,10 +829,11 @@ function ( $table ) { // Modify the table to have a multiple-row-spanning cell: // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. - $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove(); + $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove(); // - Set rowspan for 2nd cell of 3rd row to 3. // This covers the removed cell in the 4th and 5th row. - $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' ); $table.tablesorter( { sortList: [ { 0: 'asc' } @@ -804,13 +848,14 @@ function ( $table ) { // Modify the table to have a multiple-row-spanning cell: // - Remove 1st cell of 4th row, and, 1st cell or 5th row. - $table.find( 'tr:eq(3) td:eq(0), tr:eq(4) td:eq(0)' ).remove(); + $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 0 ).remove(); + $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 0 ).remove(); // - Set rowspan for 1st cell of 3rd row to 3. // This covers the removed cell in the 4th and 5th row. - $table.find( 'tr:eq(2) td:eq(0)' ).attr( 'rowspan', '3' ); + $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 0 ).attr( 'rowspan', '3' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -823,7 +868,7 @@ mw.config.set( 'wgDefaultDateFormat', 'mdy' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -834,7 +879,7 @@ currencySorted, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -848,9 +893,9 @@ planets, planetsAscNameLegacy, function ( $table ) { - $table.find( 'tr:last' ).addClass( 'sortbottom' ); + $table.find( 'tr' ).last().addClass( 'sortbottom' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -865,7 +910,7 @@ '' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.strictEqual( $table.data( 'tablesorter' ).config.parsers[ 0 ].id, @@ -910,7 +955,7 @@ 'Dolphin' + '' ); - $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); data = []; $table.find( 'tbody > tr' ).each( function ( i, tr ) { @@ -957,7 +1002,7 @@ 'H' + '' ); - $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); data = []; $table.find( 'tbody > tr' ).each( function ( i, tr ) { @@ -1009,9 +1054,7 @@ '' ); // initialize table sorter and sort once - $table - .tablesorter() - .find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); // Change the sortValue data properties (T40152) // - change data @@ -1022,8 +1065,8 @@ $table.find( 'td:contains(G)' ).removeData( 'sortValue' ); // Now sort again (twice, so it is back at Ascending) - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); data = []; $table.find( 'tbody > tr' ).each( function ( i, tr ) { @@ -1064,7 +1107,7 @@ [ 'Numbers' ], numbers, numbersAsc, function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -1072,7 +1115,7 @@ [ 'Numbers' ], numbers, reversed( numbersAsc ), function ( $table ) { $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' ); } ); // TODO add numbers sorting tests for T10115 with a different language @@ -1091,7 +1134,7 @@ $table.tablesorter(); assert.strictEqual( - $table.find( '> thead:eq(0) > tr > th.headerSort' ).length, + $table.find( '> thead > tr > th.headerSort' ).length, 1, 'Child tables inside a headercell should not interfere with sortable headers (T34888)' ); @@ -1111,7 +1154,7 @@ mw.config.set( 'wgDefaultDateFormat', 'mdy' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -1124,7 +1167,7 @@ mw.config.set( 'wgDefaultDateFormat', 'dmy' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -1137,7 +1180,7 @@ mw.config.set( 'wgDefaultDateFormat', 'dmy' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); } ); @@ -1149,7 +1192,7 @@ '1' + '' ); - $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.strictEqual( $table.find( 'td' ).first().text(), @@ -1170,7 +1213,7 @@ 'AC' + '' ); - $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.strictEqual( $table.find( 'td' ).text(), @@ -1189,7 +1232,7 @@ '4' + '' ); - $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.strictEqual( $table.find( 'td' ).text(), @@ -1323,7 +1366,7 @@ '' ); $table.tablesorter(); - assert.strictEqual( $table.find( 'tr:eq(1) th:eq(1)' ).data( 'headerIndex' ), + assert.strictEqual( $table.find( 'tr' ).eq( 1 ).find( 'th' ).eq( 1 ).data( 'headerIndex' ), 2, 'Incorrect index of sort header' ); @@ -1440,9 +1483,9 @@ '' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); // now the first row have 2 columns - $table.find( '.headerSort:eq(1)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ); parsers = $table.data( 'tablesorter' ).config.parsers; @@ -1459,7 +1502,7 @@ ); assert.strictEqual( - parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ), + parsers[ 1 ].format( $table.find( 'tbody > tr > td' ).eq( 1 ).text() ), -Infinity, 'empty cell is sorted as number -Infinity' ); @@ -1478,7 +1521,7 @@ '' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.deepEqual( tableExtract( $table ), @@ -1509,7 +1552,7 @@ '' ); $table.tablesorter(); - $table.find( '.headerSort:eq(0)' ).trigger( 'click' ); + $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ); assert.deepEqual( tableExtract( $table ), diff --git a/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js b/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js index aafcd5b217..738392a65f 100644 --- a/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js +++ b/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js @@ -33,14 +33,18 @@ // TODO abstract the double strictEquals // At first checkboxes are hidden + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsinvert' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsassociated' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); // Initiate the recentchanges module rc.init(); // By default + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsinvert' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsassociated' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); // select second option... @@ -50,7 +54,9 @@ $( '#namespace' ).trigger( 'change' ); // ... and checkboxes should be visible again + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsinvert' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), false ); + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsassociated' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), false ); // select first option ( 'all' namespace)... @@ -59,7 +65,9 @@ $( '#namespace' ).trigger( 'change' ); // ... and checkboxes should now be hidden + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsinvert' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); + // eslint-disable-next-line no-jquery/no-class-state assert.strictEqual( $( '#nsassociated' ).closest( '.mw-input-with-label' ).hasClass( 'mw-input-hidden' ), true ); // DOM cleanup diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js index fca1f7d016..a3f3cc89e5 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js @@ -652,6 +652,14 @@ title: 'File:Foo.JPEG ', expected: 'File:Foo.JPEG', description: 'Page in File-namespace with trailing whitespace' + }, + { + title: 'File:Foo', + description: 'File name without file extension' + }, + { + title: 'File:Foo.', + description: 'File name with empty file extension' } ]; diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js index 1c7d8ee14a..2fcf61ec53 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js @@ -1281,8 +1281,16 @@ ); mw.config.set( 'wgUserLanguage', 'qqx' ); + $bar = $( '' ).text( 'bar' ); - assert.strictEqual( mw.message( 'foo', $bar, 'baz' ).parse(), '(foo: bar, baz)', 'qqx message with parameters' ); + mw.messages.set( 'qqx-message', '(qqx-message)' ); + mw.messages.set( 'non-qqx-message', 'hello world' ); + + assert.strictEqual( mw.message( 'missing-message' ).parse(), '(missing-message)', 'qqx message (missing)' ); + assert.strictEqual( mw.message( 'missing-message', $bar, 'baz' ).parse(), '(missing-message: bar, baz)', 'qqx message (missing) with parameters' ); + assert.strictEqual( mw.message( 'qqx-message' ).parse(), '(qqx-message)', 'qqx message (defined)' ); + assert.strictEqual( mw.message( 'qqx-message', $bar, 'baz' ).parse(), '(qqx-message: bar, baz)', 'qqx message (defined) with parameters' ); + assert.strictEqual( mw.message( 'non-qqx-message' ).parse(), 'hello world', 'non-qqx message in qqx mode' ); } ); QUnit.test( 'setParserDefaults', function ( assert ) { diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js index 425e18e6f5..08262b2f28 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -334,8 +334,15 @@ ); mw.config.set( 'wgUserLanguage', 'qqx' ); - assert.strictEqual( mw.message( 'foo' ).plain(), '(foo)', 'qqx message' ); - assert.strictEqual( mw.message( 'foo', 'bar', 'baz' ).plain(), '(foo: bar, baz)', 'qqx message with parameters' ); + + mw.messages.set( 'qqx-message', '(qqx-message)' ); + mw.messages.set( 'non-qqx-message', 'hello world' ); + + assert.strictEqual( mw.message( 'missing-message' ).plain(), '(missing-message)', 'qqx message (missing)' ); + assert.strictEqual( mw.message( 'missing-message', 'bar', 'baz' ).plain(), '(missing-message: bar, baz)', 'qqx message (missing) with parameters' ); + assert.strictEqual( mw.message( 'qqx-message' ).plain(), '(qqx-message)', 'qqx message (defined)' ); + assert.strictEqual( mw.message( 'qqx-message', 'bar', 'baz' ).plain(), '(qqx-message: bar, baz)', 'qqx message (defined) with parameters' ); + assert.strictEqual( mw.message( 'non-qqx-message' ).plain(), 'hello world', 'non-qqx message in qqx mode' ); } ); QUnit.test( 'mw.msg', function ( assert ) { diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js index 74fd743bb5..6dab026b43 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js @@ -21,17 +21,18 @@ $( '#qunit-fixture' ).append( $toc ); mw.hook( 'wikipage.content' ).fire( $( '#qunit-fixture' ) ); - $tocList = $toc.find( 'ul:first' ); + $tocList = $toc.find( 'ul' ).first(); $toggleLink = $toc.find( '.togglelink' ); assert.strictEqual( $toggleLink.length, 1, 'Toggle link is added to the table of contents' ); - assert.strictEqual( $tocList.is( ':hidden' ), false, 'The table of contents is now visible' ); + // eslint-disable-next-line no-jquery/no-class-state + assert.strictEqual( $toc.hasClass( 'tochidden' ), false, 'The table of contents is now visible' ); $toggleLink.trigger( 'click' ); return $tocList.promise().then( function () { - assert.strictEqual( $tocList.is( ':hidden' ), true, 'The table of contents is now hidden' ); - + // eslint-disable-next-line no-jquery/no-class-state + assert.strictEqual( $toc.hasClass( 'tochidden' ), true, 'The table of contents is now hidden' ); $toggleLink.trigger( 'click' ); return $tocList.promise(); } ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js index 6b316e5558..3679ed76f9 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js @@ -232,11 +232,12 @@ } ); QUnit.test( 'wikiScript', function ( assert ) { + mw.util.setOptionsForTest( { + LoadScript: '/w/l.php' + } ); mw.config.set( { // customized wgScript for T41103 wgScript: '/w/i.php', - // customized wgLoadScript for T41103 - wgLoadScript: '/w/l.php', wgScriptPath: '/w' } ); @@ -246,8 +247,8 @@ assert.strictEqual( util.wikiScript( 'index' ), mw.config.get( 'wgScript' ), 'wikiScript( index ) returns wgScript' ); - assert.strictEqual( util.wikiScript( 'load' ), mw.config.get( 'wgLoadScript' ), - 'wikiScript( load ) returns wgLoadScript' + assert.strictEqual( util.wikiScript( 'load' ), '/w/l.php', + 'wikiScript( load ) returns /w/l.php' ); assert.strictEqual( util.wikiScript( 'api' ), '/w/api.php', 'API path' ); } ); @@ -388,7 +389,7 @@ 'Default modules', 't-rldm-nonexistent', 'List of all default modules ', 'd', '#t-rl-nonexistent' ); assert.strictEqual( tbRLDMnonexistentid, - $( '#p-test-tb li:last' )[ 0 ], + $( '#p-test-tb li' ).last()[ 0 ], 'Next node as non-matching CSS selector falls back to appending' ); @@ -396,7 +397,7 @@ 'Default modules', 't-rldm-empty-jquery', 'List of all default modules ', 'd', $( '#t-rl-nonexistent' ) ); assert.strictEqual( tbRLDMemptyjquery, - $( '#p-test-tb li:last' )[ 0 ], + $( '#p-test-tb li' ).last()[ 0 ], 'Next node as empty jQuery object falls back to appending' ); } ); diff --git a/tests/selenium/.eslintrc.json b/tests/selenium/.eslintrc.json index dd766c85fb..21d230e004 100644 --- a/tests/selenium/.eslintrc.json +++ b/tests/selenium/.eslintrc.json @@ -11,6 +11,7 @@ "mw": false }, "rules": { - "no-console": 0 + "no-console": "off", + "prefer-template": "off" } } diff --git a/tests/selenium/pageobjects/history.page.js b/tests/selenium/pageobjects/history.page.js index 52d614fba7..730eff36c4 100644 --- a/tests/selenium/pageobjects/history.page.js +++ b/tests/selenium/pageobjects/history.page.js @@ -29,7 +29,7 @@ class HistoryPage extends Page { } vandalizePage( name, content ) { - let vandalUsername = 'Evil_' + browser.options.username; + const vandalUsername = 'Evil_' + browser.options.username; browser.call( function () { return Api.edit( name, content ); diff --git a/tests/selenium/specs/page.js b/tests/selenium/specs/page.js index db67dde94d..ab547e87dc 100644 --- a/tests/selenium/specs/page.js +++ b/tests/selenium/specs/page.js @@ -49,7 +49,7 @@ describe( 'Page', function () { } ); it( 'should be re-creatable', function () { - let initialContent = Util.getTestString( 'initialContent-' ); + const initialContent = Util.getTestString( 'initialContent-' ); // create browser.call( function () { @@ -76,7 +76,7 @@ describe( 'Page', function () { } ); // edit - let editContent = Util.getTestString( 'editContent-' ); + const editContent = Util.getTestString( 'editContent-' ); EditPage.edit( name, editContent ); // check diff --git a/tests/selenium/specs/rollback.js b/tests/selenium/specs/rollback.js index bf0dc910d6..daf7112e63 100644 --- a/tests/selenium/specs/rollback.js +++ b/tests/selenium/specs/rollback.js @@ -32,7 +32,7 @@ describe( 'Rollback with confirmation', function () { HistoryPage.open( name ); } ); - it( 'should offer rollback options for admin users', function () { + it.skip( 'should offer rollback options for admin users', function () { assert.strictEqual( HistoryPage.rollback.getText(), 'rollback 1 edit' ); HistoryPage.rollback.click(); @@ -42,7 +42,7 @@ describe( 'Rollback with confirmation', function () { assert.strictEqual( HistoryPage.rollbackConfirmableNo.getText(), 'Cancel' ); } ); - it( 'should offer a way to cancel rollbacks', function () { + it.skip( 'should offer a way to cancel rollbacks', function () { HistoryPage.rollback.click(); HistoryPage.rollbackConfirmableNo.waitForVisible( 5000 ); @@ -67,7 +67,7 @@ describe( 'Rollback with confirmation', function () { }, 5000, 'Expected rollback page to appear.' ); } ); - it( 'should verify rollbacks via GET requests are confirmed on a follow-up page', function () { + it.skip( 'should verify rollbacks via GET requests are confirmed on a follow-up page', function () { var rollbackActionUrl = HistoryPage.rollbackLink.getAttribute( 'href' ); browser.url( rollbackActionUrl ); @@ -121,7 +121,7 @@ describe( 'Rollback without confirmation', function () { }, 5000, 'Expected rollback page to appear.' ); } ); - it( 'should perform rollback via GET request without asking the user to confirm', function () { + it.skip( 'should perform rollback via GET request without asking the user to confirm', function () { var rollbackActionUrl = HistoryPage.rollbackLink.getAttribute( 'href' ); browser.url( rollbackActionUrl ); diff --git a/tests/selenium/wdio-mediawiki/Api.js b/tests/selenium/wdio-mediawiki/Api.js index 7947ff504c..6b674b99f9 100644 --- a/tests/selenium/wdio-mediawiki/Api.js +++ b/tests/selenium/wdio-mediawiki/Api.js @@ -22,7 +22,7 @@ module.exports = { password = browser.options.password, baseUrl = browser.options.baseUrl ) { - let bot = new MWBot(); + const bot = new MWBot(); return bot.loginGetEditToken( { apiUrl: `${baseUrl}/api.php`, @@ -43,7 +43,7 @@ module.exports = { * @return {Object} Promise for API action=delete response data. */ delete( title, reason ) { - let bot = new MWBot(); + const bot = new MWBot(); return bot.loginGetEditToken( { apiUrl: `${browser.options.baseUrl}/api.php`, @@ -64,7 +64,7 @@ module.exports = { * @return {Object} Promise for API action=createaccount response data. */ createAccount( username, password ) { - let bot = new MWBot(); + const bot = new MWBot(); // Log in as admin return bot.loginGetCreateaccountToken( { @@ -94,7 +94,7 @@ module.exports = { * @return {Object} Promise for API action=block response data. */ blockUser( username, expiry ) { - let bot = new MWBot(); + const bot = new MWBot(); // Log in as admin return bot.loginGetEditToken( { @@ -122,7 +122,7 @@ module.exports = { * @return {Object} Promise for API action=unblock response data. */ unblockUser( username ) { - let bot = new MWBot(); + const bot = new MWBot(); // Log in as admin return bot.loginGetEditToken( { diff --git a/tests/selenium/wdio-mediawiki/RunJobs.js b/tests/selenium/wdio-mediawiki/RunJobs.js index 070ad56498..2e675c027f 100644 --- a/tests/selenium/wdio-mediawiki/RunJobs.js +++ b/tests/selenium/wdio-mediawiki/RunJobs.js @@ -3,7 +3,7 @@ const MWBot = require( 'mwbot' ), MAINPAGE_REQUESTS_MAX_RUNS = 10; // (arbitrary) safe-guard against endless execution function getJobCount() { - let bot = new MWBot( { + const bot = new MWBot( { apiUrl: `${browser.options.baseUrl}/api.php` } ); return bot.request( { @@ -20,7 +20,7 @@ function log( message ) { } function runThroughMainPageRequests( runCount = 1 ) { - let page = new Page(); + const page = new Page(); log( `through requests to the main page (run ${runCount}).` ); page.openTitle( '' ); diff --git a/tests/selenium/wdio.conf.js b/tests/selenium/wdio.conf.js index 5b4a9d5226..214c25add9 100644 --- a/tests/selenium/wdio.conf.js +++ b/tests/selenium/wdio.conf.js @@ -65,6 +65,8 @@ exports.config = { // Patterns to exclude exclude: [ relPath( './extensions/CirrusSearch/tests/selenium/specs/**/*.js' ), + // Disabled because these tests modify LocalSettings.php, see T199116 for the long-term fix. + relPath( './extensions/FileImporter/tests/selenium/specs/**/*.js' ), // Disabled per T222517 relPath( './skins/MinervaNeue/tests/selenium/specs/**/*.js' ) ], @@ -158,7 +160,7 @@ exports.config = { beforeTest: function ( test ) { if ( process.env.DISPLAY && process.env.DISPLAY.startsWith( ':' ) ) { var logBuffer; - let videoPath = filePath( test, logPath, 'mp4' ); + const videoPath = filePath( test, logPath, 'mp4' ); const { spawn } = require( 'child_process' ); ffmpeg = spawn( 'ffmpeg', [ '-f', 'x11grab', // grab the X11 display @@ -171,7 +173,7 @@ exports.config = { ] ); logBuffer = function ( buffer, prefix ) { - let lines = buffer.toString().trim().split( '\n' ); + const lines = buffer.toString().trim().split( '\n' ); lines.forEach( function ( line ) { console.log( prefix + line ); } ); @@ -213,7 +215,7 @@ exports.config = { return; } // save screenshot - let screenshotfile = filePath( test, logPath, 'png' ); + const screenshotfile = filePath( test, logPath, 'png' ); browser.saveScreenshot( screenshotfile ); console.log( '\n\tScreenshot location:', screenshotfile, '\n' ); } diff --git a/thumb.php b/thumb.php index 70329093c6..4e5c2134d3 100644 --- a/thumb.php +++ b/thumb.php @@ -155,7 +155,11 @@ function wfStreamThumb( array $params ) { // Check permissions if there are read restrictions $varyHeader = []; if ( !in_array( 'read', User::getGroupPermissions( [ '*' ] ), true ) ) { - if ( !$img->getTitle() || !$img->getTitle()->userCan( 'read' ) ) { + $user = RequestContext::getMain()->getUser(); + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + $imgTitle = $img->getTitle(); + + if ( !$imgTitle || !$permissionManager->userCan( 'read', $user, $imgTitle ) ) { wfThumbError( 403, 'Access denied. You do not have permission to access ' . 'the source file.' ); return; @@ -272,7 +276,7 @@ function wfStreamThumb( array $params ) { // For 404 handled thumbnails, we only use the base name of the URI // for the thumb params and the parent directory for the source file name. - // Check that the zone relative path matches up so squid caches won't pick + // Check that the zone relative path matches up so CDN caches won't pick // up thumbs that would not be purged on source file deletion (T36231). if ( $rel404 !== null ) { // thumbnail was handled via 404 if ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName ) ) { @@ -409,6 +413,8 @@ function wfProxyThumbnailRequest( $img, $thumbName ) { // Send request to proxied service $status = $req->execute(); + MediaWiki\HeaderCallback::warnIfHeadersSent(); + // Simply serve the response from the proxied service as-is header( 'HTTP/1.1 ' . $req->getStatus() ); @@ -634,6 +640,8 @@ function wfThumbErrorText( $status, $msgText ) { function wfThumbError( $status, $msgHtml, $msgText = null, $context = [] ) { global $wgShowHostnames; + MediaWiki\HeaderCallback::warnIfHeadersSent(); + header( 'Cache-Control: no-cache' ); header( 'Content-Type: text/html; charset=utf-8' ); if ( $status == 400 || $status == 404 || $status == 429 ) {