From d314bb854039a07f9eec412d0a55f0f586ac1183 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 17 Sep 2026 12:11:52 +0200 Subject: [PATCH 01/29] Migrate Language component to the Component Revision architecture and introduce Activities --- .../tests/ilCertificateDateHelperTest.php | 2 +- components/ILIAS/File/File.php | 7 + components/ILIAS/File/lang/ilias_de.lang | 115 + components/ILIAS/File/lang/ilias_en.lang | 115 + components/ILIAS/Init/Init.php | 11 +- .../ILIAS/Init/src/AllModernComponents.php | 20 +- components/ILIAS/Language/Language.php | 281 ++- components/ILIAS/Language/README.md | 122 +- components/ILIAS/Language/ROADMAP.md | 16 +- ...ss.ilLanguageMetricsCollectedObjective.php | 20 +- .../Setup/class.ilLanguageObjective.php | 7 + .../Setup/class.ilLanguageSetupAgent.php | 28 +- ...lLanguagesInstalledAndUpdatedObjective.php | 110 +- .../class.ilLanguagesUpdatedObjective.php | 103 - .../classes/Setup/class.ilSetupLanguage.php | 578 ++---- .../Language/classes/class.ilLanguage.php | 10 +- .../classes/class.ilLanguageFolderTable.php | 11 +- .../Language/classes/class.ilObjLanguage.php | 259 ++- .../classes/class.ilObjLanguageAccess.php | 6 +- .../classes/class.ilObjLanguageDBAccess.php | 185 -- .../classes/class.ilObjLanguageExt.php | 6 +- .../classes/class.ilObjLanguageExtGUI.php | 153 +- .../classes/class.ilObjLanguageFolderGUI.php | 690 ++++-- .../src/Activities/AddLanguageEntry.php | 319 +++ .../AmbiguousLanguageTitleException.php | 25 + .../DeclaresLanguageKeysOnlyInput.php | 52 + .../src/Activities/GrindingInputAccess.php | 134 ++ .../src/Activities/GrindsFormInput.php | 222 ++ .../src/Activities/InstallLanguage.php | 257 +++ .../src/Activities/InvalidInputException.php | 25 + .../src/Activities/LanguageActivity.php | 132 ++ .../src/Activities/ParsesLanguageKeyList.php | 71 + .../Activities/RemoveLocalLanguageChanges.php | 160 ++ .../ResolvesLanguageKeysToObjIds.php | 59 + .../Activities/SafeToDisplayActivityError.php | 25 + .../SetLanguageDetectionEnabled.php | 104 + .../SetLanguageTranslationEnabled.php | 154 ++ .../src/Activities/UninstallLanguage.php | 172 ++ .../src/Activities/UpdateLanguage.php | 141 ++ .../ComponentLanguageFileDirectory.php | 68 + .../CustomizingLanguageFileDirectory.php | 30 + .../LanguageFileDirectory.php | 47 + .../LanguageFileDirectoryManager.php | 113 + .../MainLanguageFileDirectory.php | 48 + .../Language/src/RendersActivityErrors.php | 111 + .../InstalledLanguageDatabaseRepository.php | 343 +++ .../src/Setup/InstalledLanguageRepository.php | 116 ++ .../src/Setup/LanguageFileParsing.php | 53 + .../src/Setup/LanguageInstallationManager.php | 432 ++++ .../Activities/ActivityContractTestCase.php | 97 + ...ivityWithPerformResultContractTestCase.php | 57 + .../tests/Activities/AddLanguageEntryTest.php | 1497 +++++++++++++ .../tests/Activities/GrindsFormInputTest.php | 300 +++ .../Activities/GrindsFormInputTestHost.php | 40 + .../tests/Activities/InstallLanguageTest.php | 705 +++++++ .../tests/Activities/LanguageActivityTest.php | 220 ++ .../tests/Activities/RealFieldsUiFactory.php | 94 + .../RemoveLocalLanguageChangesTest.php | 763 +++++++ .../SetLanguageDetectionEnabledTest.php | 445 ++++ .../SetLanguageTranslationEnabledTest.php | 718 +++++++ .../Activities/UninstallLanguageTest.php | 750 +++++++ .../tests/Activities/UpdateLanguageTest.php | 409 ++++ .../ComponentLanguageFileDirectoryTest.php | 98 + .../tests/LanguageComponentGraphTest.php | 284 +++ .../tests/RendersActivityErrorsTest.php | 276 +++ ...nstalledLanguageDatabaseRepositoryTest.php | 522 +++++ .../Setup/LanguageInstallationManagerTest.php | 964 +++++++++ ...guagesInstalledAndUpdatedObjectiveTest.php | 233 +++ components/ILIAS/Language/tests/bootstrap.php | 2 +- .../tests/ilLanguageSetupAgentTest.php | 25 +- .../tests/ilObjLanguageDBAccessTest.php | 107 - .../tests/ilObjLanguageExtGUITest.php | 594 ++++++ .../tests/ilObjLanguageFolderGUITest.php | 1848 +++++++++++++++++ .../Language/tests/ilObjLanguageTest.php | 62 + .../Language/tests/ilSetupLanguageTest.php | 321 ++- docs/development/language.md | 21 +- lang/ilias_de.lang | 84 - lang/ilias_en.lang | 84 - 78 files changed, 16419 insertions(+), 1469 deletions(-) create mode 100644 components/ILIAS/File/lang/ilias_de.lang create mode 100644 components/ILIAS/File/lang/ilias_en.lang delete mode 100755 components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php delete mode 100755 components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php create mode 100644 components/ILIAS/Language/src/Activities/AddLanguageEntry.php create mode 100644 components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php create mode 100644 components/ILIAS/Language/src/Activities/DeclaresLanguageKeysOnlyInput.php create mode 100644 components/ILIAS/Language/src/Activities/GrindingInputAccess.php create mode 100644 components/ILIAS/Language/src/Activities/GrindsFormInput.php create mode 100644 components/ILIAS/Language/src/Activities/InstallLanguage.php create mode 100644 components/ILIAS/Language/src/Activities/InvalidInputException.php create mode 100644 components/ILIAS/Language/src/Activities/LanguageActivity.php create mode 100644 components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php create mode 100644 components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php create mode 100644 components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php create mode 100644 components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php create mode 100644 components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php create mode 100644 components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php create mode 100644 components/ILIAS/Language/src/Activities/UninstallLanguage.php create mode 100644 components/ILIAS/Language/src/Activities/UpdateLanguage.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/LanguageFileDirectoryManager.php create mode 100644 components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php create mode 100644 components/ILIAS/Language/src/RendersActivityErrors.php create mode 100644 components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php create mode 100644 components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php create mode 100644 components/ILIAS/Language/src/Setup/LanguageFileParsing.php create mode 100644 components/ILIAS/Language/src/Setup/LanguageInstallationManager.php create mode 100644 components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php create mode 100644 components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php create mode 100644 components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php create mode 100644 components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php create mode 100644 components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php create mode 100644 components/ILIAS/Language/tests/Activities/InstallLanguageTest.php create mode 100644 components/ILIAS/Language/tests/Activities/LanguageActivityTest.php create mode 100644 components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php create mode 100644 components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php create mode 100644 components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php create mode 100644 components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php create mode 100644 components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php create mode 100644 components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php create mode 100644 components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php create mode 100644 components/ILIAS/Language/tests/LanguageComponentGraphTest.php create mode 100644 components/ILIAS/Language/tests/RendersActivityErrorsTest.php create mode 100644 components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php create mode 100644 components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php create mode 100644 components/ILIAS/Language/tests/Setup/ilLanguagesInstalledAndUpdatedObjectiveTest.php delete mode 100755 components/ILIAS/Language/tests/ilObjLanguageDBAccessTest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php create mode 100644 components/ILIAS/Language/tests/ilObjLanguageTest.php diff --git a/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php b/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php index 516aae78b4c1..cf8941f1c3e5 100644 --- a/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php +++ b/components/ILIAS/Certificate/tests/ilCertificateDateHelperTest.php @@ -333,7 +333,7 @@ private function mockUserLanguageGerman(): void $ilDB->method('query')->willReturnCallback(function ($query): ilDBStatement { $statement = $this->createMock(ilDBStatement::class); - if (str_contains($query, 'SELECT * FROM lng_modules')) { + if (str_contains($query, 'FROM lng_modules')) { $statement->method('numRows')->willReturn(1); $statement->method('fetchRow')->willReturn([ 'lang_array' => serialize([ diff --git a/components/ILIAS/File/File.php b/components/ILIAS/File/File.php index 6d44f47a72dc..c33340f2e693 100644 --- a/components/ILIAS/File/File.php +++ b/components/ILIAS/File/File.php @@ -46,6 +46,8 @@ use ILIAS\FileUpload\Activities\Parameter; use ILIAS\Setup\Agent; use ILIAS\Refinery\Factory; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; class File implements Component { @@ -195,5 +197,10 @@ public function init( $contribute[Activity::class] = static fn() => $internal[ListFileVersions::class]; $contribute[Activity::class] = static fn() => $internal[DeliverFileVersion::class]; $contribute[Activity::class] = static fn() => $internal[DownloadContainerAsZip::class]; + + $contribute[LanguageFileDirectory::class] = fn(): LanguageFileDirectory => new ComponentLanguageFileDirectory( + $this, + 'file' + ); } } diff --git a/components/ILIAS/File/lang/ilias_de.lang b/components/ILIAS/File/lang/ilias_de.lang new file mode 100644 index 000000000000..573ab640fdd6 --- /dev/null +++ b/components/ILIAS/File/lang/ilias_de.lang @@ -0,0 +1,115 @@ +/** +* This file is part of ILIAS, a powerful learning management system +* published by ILIAS open source e-Learning e.V. +* +* ILIAS is licensed with the GPL-3.0, +* see https://www.gnu.org/licenses/gpl-3.0.en.html +* You should have received a copy of said license along with the +* source code, too. +* +* If this is not the case or you just want to try ILIAS, you'll find +* us at: +* https://www.ilias.de +* https://github.com/ILIAS-eLearning +* +******************************************************************** +*/ +/** +* ILIAS language file +* +* @module language file German +* @modulegroup language +* @author Matthias Kunkel +* @version $Id$ +*/ +// The language file starts beyond the HTML-comment below. DO NOT modify this line! +// To edit your language file with a spreadsheet (i.e. Excel or StarCalc) remove all lines +// from the first line to the HTML-comment. After editing paste the lines in place again. +// NOTICE: Character coding of all ILIAS lang files is UTF-8! Please set your editor +// to the corresponding mode! +// Language file names refer to ISO 639, see: http://www.oasis-open.org/cover/iso639a.html + +add_icon#:#Icon hinzufügen +amount_of_downloads#:#Downloads +amount_of_downloads_since#:#%d seit %s +copyright_custom#:#Benutzerdefiniert +copyright_custom_info#:#Lizenz auswählen, welche auf alle entpackten Dateien dieses Archivs angewandt wird. +copyright_inherited#:#Vererbt +copyright_inherited_info#:#Lizenz des Zip-Archivs auf seine entpackten Dateien anwenden.
Lizenz des Zip-Archivs: %s. +could_not_create_file_objs#:#Beim Erstellen der Dateiobjekte ist ein Fehler aufgetreten, wenden Sie sich an die Administrierenden der Plattform. +de_activate_icon#:#(De-)Aktivieren +download_ascii_filename#:#Nur ASCII-Zeichen im Download +download_ascii_filename_info#:#Die Namen heruntergeladener Dateien enthalten nur ASCII-Zeichen und können so auf allen Systemen problemlos genutzt werden. Andere Zeichen werden durch Unterstriche ersetzt. Auf Installationen, die Dateinamen mit nicht-lateinischen Schriftzeichen nutzen, muss diese Option deaktiviert werden. +file_action_download#:#Datei herunterladen oder Inhalt anzeigen +file_action_download_info#:#Wenn WOPI aktiviert ist und die Berechtigung „Inhalt anzeigen“ erteilt wurde, wird die Datei auf der Registerkarte „Inhalt“ angezeigt. Wenn nicht, wird die Datei zum direkten Download angeboten. +file_action_show#:#Zunächst Reiter „Info“ anzeigen +file_action_show_info#:#Der Reiter „Info“ wird angezeigt, wo die Datei in einem zweiten Schritt heruntergeladen werden kann. +file_btn_lp_toggle_state_completed#:#Auf „Nicht bearbeitet“ setzen +file_btn_lp_toggle_state_not_completed#:#Auf „Bearbeitet“ setzen +file_copy#:#Datei kopieren +file_download#:#Datei herunterladen +file_import#:#Dateiimport +file_new_version#:#Neue Version anlegen +file_new_version_info#:#Eine neue Dateiversion anlegen. Alte Versionen bleiben erhalten. +file_publish#:#Entwurf veröffentlichen +file_rollback_rollback_first#:#Die Version konnte nicht zurückgesetzt werden, weil ein unveröffentlichter Entwurf existiert. +file_rollback_same_version#:#Dies ist bereits die veröffentlichte Version +file_unpublish#:#Als Entwurf markieren +file_upload_info_file_with_critical_extension#:#Mindestens eine Datei enthält eine kritische oder unbekannte Dateiendung. Die Dateiendung wird bei jedem Download zu .sec geändert. Bitte kontaktieren Sie gegebenenfalls Ihre technische Betreuung. Dateiname(n): %s +file_uploaded_by#:#Hochgeladen von +file_version_draft#:#Entwurf +file_version_draft_info#:#Die aktuellste Version ist im Status „Entwurf“. Solange diese Version nicht veröffentlicht wurde, können keine neuen Versionen angelegt werden. Personen mit Leserecht auf die Datei erhalten die aktuell veröffentlichte Version. +form_icon_creation#:#Icon erstellen +form_icon_updating#:#Icon aktualisieren +general_upload_error_occured#:#Beim Verarbeiten der Datei ist ein unerwartetet Fehler aufgetreten. +important_info#:#Wichtige Informationen +important_info_byline#:#Die Angaben werden im Reiter „Info“ angezeigt. +input_active#:#Aktiv +input_desc_active#:#Dieses Icon aktivieren. +input_desc_icon#:#Bild welches als Icon für Dateien mit den spezifizierten Suffixen verwendet werden soll. +input_desc_suffixes#:#Liste Komma-separierter Suffixe (Nur Buchstaben und Zahlen ohne vorangehenden Punkt). +input_icon#:#Icon +input_suffixes#:#Suffix(e) +migrated#:#Status +mime_type#:#MIME-Type +msg_cant_unpublish#:#Aktion konnte nicht ausgeführt werden +msg_confirm_entry_deletion#:#Wollen Sie den nachfolgenden Eintrag wirklich löschen?: +msg_error_active_suffixes_blacklisted#:#Eine der gewählten Dateiendungen ist auf der globalen Blacklist und kann derzeit nicht genutzt werden. +msg_error_active_suffixes_conflict#:#Fehler: Es dürfen nicht mehrere Icons für ein Suffix aktiv sein. Bitte deaktivieren Sie entweder dieses Icon oder das andere aktivierte Icon, dessen Suffixe sich mit jenen dieses Icons überschneiden. +msg_error_active_suffixes_not_whitelisted#:#Die gewählte Dateiendung ist nicht auf der globalen Whitelist, beim Download werden die Dateine zu .sec. umbenannt. +msg_error_duplicate_suffix_entries#:#Fehler: Die Komma-separierte Suffix-Liste enthält Duplikate. +msg_error_icon_deletion#:#Fehler: Icon Löschung fehlgeschlagen. +msg_error_suffixes_with_forbidden_characters#:#Fehler: unerlaubte Zeichen. Nur Buchstaben, Nummern, Leerzeichen und Kommas sind erlaubt. +msg_icon_missing_from_db#:#Icon ist nicht in der Datenbank. +msg_icon_missing_from_irss#:#Icon ist nicht im Resource Storage. +msg_success_icon_activated#:#Icon erfolgreich aktiviert. +msg_success_icon_created#:#Icon erfolgreich erstellt. +msg_success_icon_deactivated#:#Icon erfolgreich deaktiviert. +msg_success_icon_deletion#:#Icon erfolgreich gelöscht. +msg_success_icon_updated#:#Icon erfolgreich aktualisiert. +msg_unzip_success#:#Archiv wurde erfolreich entpackt. +on_click_action#:#Klick auf Titel +preview_caption#:#Vorschau %svon %s +preview_image_size_info#:#Die Vorschauversionen der Bilder werden je nach Bedarf verkleinert oder vergrößert, so dass ihre längste Seite der hier eingegebenen Länge (in px) entspricht. +preview_persisting#:#Vorschaubilder speichern +preview_persisting_info#:#Vorschaubilder werden generiert und gespeichert. Sie müssen nicht mehr bei jedem Aufruf neu erzeugt werden. Das verbessert die Performanz. +previews_for_tiles#:#Kachelbilder +previews_for_tiles_info#:#Verwende Vorschaubilder für Kacheln. +publish_before_delete#:#Version(en) konnte(n) nicht gelöscht werden, da ein noch unveröffentlichter Entwurf existiert. +replace_file_info#:#Alle früheren Dateiversionen werden gelöscht. +resource_id#:#Ressourcen ID +service_settings#:#Weitere Funktionen +service_settings_saved#:#Gespeichert +set_license_for_all_files#:#Lizenz für alle Dateien festlegen +show_amount_of_downloads#:#Anzahl Downloads anzeigen +show_amount_of_downloads_info#:#Die Anzahl der Downloads wird im Reiter „Info“ des Dateiobjekts angezeigt. +storage_id#:#Storage ID +suffix_specific_icons#:#Suffix-spezifische Icons +suffixes#:#Suffix(e) +upload_files#:#Dateien hochladen +upload_files_limit#:#Die maximale Größe einer Datei beträgt %s. +upload_files_title#:#Dateien hochladen +upload_info#:#Datei +upload_info_desc#:#Sie können die Dateien und die Dateiversionen im Tab "Versionen" verwalten. +version_uploaded#:#Version hochgeladen am +versionname#:#Angezeigter Titel \ No newline at end of file diff --git a/components/ILIAS/File/lang/ilias_en.lang b/components/ILIAS/File/lang/ilias_en.lang new file mode 100644 index 000000000000..79ad17b707bd --- /dev/null +++ b/components/ILIAS/File/lang/ilias_en.lang @@ -0,0 +1,115 @@ +/** +* This file is part of ILIAS, a powerful learning management system +* published by ILIAS open source e-Learning e.V. +* +* ILIAS is licensed with the GPL-3.0, +* see https://www.gnu.org/licenses/gpl-3.0.en.html +* You should have received a copy of said license along with the +* source code, too. +* +* If this is not the case or you just want to try ILIAS, you'll find +* us at: +* https://www.ilias.de +* https://github.com/ILIAS-eLearning +* +******************************************************************** +*/ +/** +* ILIAS language file +* +* @module language file English +* @modulegroup language +* @author Matthias Kunkel , Chris Potter +* @version $Id$ +*/ +// The language file starts beyond the HTML-comment below. DO NOT modify this line! +// To edit your language file with a spreadsheet (i.e. Excel or StarCalc) remove all lines +// from the first line to the HTML-comment. After editing paste the lines in place again. +// NOTICE: Character coding of all ILIAS lang files is UTF-8! Please set your editor +// to the corresponding mode! +// Language file names refer to ISO 639, see: http://www.oasis-open.org/cover/iso639a.html + +add_icon#:#Add Icon +amount_of_downloads#:#Downloads +amount_of_downloads_since#:#%d times since %s +copyright_custom#:#Custom +copyright_custom_info#:#Choose a custom copyright which will be applied to all unzipped files of this archive. +copyright_inherited#:#Inherited +copyright_inherited_info#:#Apply the copyright of the zip archive to its unzipped files.
Copyright of zip archive: %s. +could_not_create_file_objs#:#An error occurred while creating your file objects. Please contact the administrators of this platform. +de_activate_icon#:#Activate / Deactivate +download_ascii_filename#:#Allow Only ASCII Characters in Downloaded Filenames +download_ascii_filename_info#:#Downloaded files should only have ASCII-characters in their filename. Deactivate to use all characters. +file_action_download#:#Download file or view content +file_action_download_info#:#If WOPI is activated and the permission 'View Content' is given, the file is shown in tab 'Content'. If not, the file will be offered for direct download. +file_action_show#:#Redirect user to the file’s ‘Info’ tab +file_action_show_info#:#The 'Info' tab is shown where the file could be downloaded in a second step. +file_btn_lp_toggle_state_completed#:#Set Not Completed +file_btn_lp_toggle_state_not_completed#:#Set Completed +file_copy#:#Copy File +file_download#:#Download File +file_import#:#Import File +file_new_version#:#Create New Version +file_new_version_info#:#Create new file version. Previous versions will not be modified. +file_publish#:#Publish Draft +file_rollback_rollback_first#:#The selected version could not be published because an unpublished draft exists. +file_rollback_same_version#:#This is already the published version! +file_unpublish#:#Mark as Draft +file_upload_info_file_with_critical_extension#:#At least one uploaded file contains a critical or unknown file ending. Whenever the file is downloaded, its ending will be changed to ‘.sec’. If necessary, contact your administrator. Filename(s): %s +file_uploaded_by#:#Uploaded By +file_version_draft#:#Draft Version +file_version_draft_info#:#The latest version of this file has the status ‘Draft’. As long as this version has not been published, no new versions can be created. People with read permission for the file get the most recent previously published version. +form_icon_creation#:#Create Icon +form_icon_updating#:#Update Icon +general_upload_error_occured#:#An unexpected error occurred during upload. +important_info#:#Important Information +important_info_byline#:#The information will be displayed in the ‘Info’ tab. +input_active#:#Active +input_desc_active#:#Activate this icon. +input_desc_icon#:#Image to be used as the icon for files with the specified suffixes. +input_desc_suffixes#:#List of comma-separated suffixes (characters and numbers only, without preceding full stop). +input_icon#:#Icon +input_suffixes#:#Suffix(es) +migrated#:#Status +mime_type#:#MIME Type +msg_cant_unpublish#:#File could not be unpublished. +msg_confirm_entry_deletion#:#Are you sure you want to delete the following entry?: +msg_error_active_suffixes_blacklisted#:#One of the selected file extensions is on the global blacklist and cannot therefore be currently used. +msg_error_active_suffixes_conflict#:#Error: It is not possible to have multiple icons activated for the same suffix. Please deactivate either this icon or the other activated icon whose suffixes overlap with those of this icon. +msg_error_active_suffixes_not_whitelisted#:#The selected file extension is not on the global whitelist. The file suffixes will be changed to ‘.sec’ when downloaded. +msg_error_duplicate_suffix_entries#:#Error: the comma separated list of suffixes contains duplicate entries. +msg_error_icon_deletion#:#Error: icon deletion failed. +msg_error_suffixes_with_forbidden_characters#:#Error: forbidden characters. Only letters, numbers, spaces and commas are allowed. +msg_icon_missing_from_db#:#Icon missing from database. +msg_icon_missing_from_irss#:#Icon missing from resource storage. +msg_success_icon_activated#:#Icon successfully activated. +msg_success_icon_created#:#Icon successfully created. +msg_success_icon_deactivated#:#Icon successfully deactivated. +msg_success_icon_deletion#:#Icon successfully deleted. +msg_success_icon_updated#:#Icon successfully updated. +msg_unzip_success#:#Archive successfully unzipped. +on_click_action#:#Action When Title Clicked +preview_caption#:#Preview %sof %s +preview_image_size_info#:#The preview versions of images will be downscaled or upscaled as appropriate, so that their longest side is the length (in px) entered here. +preview_persisting#:#Persistent Preview Images +preview_persisting_info#:#Generated preview images will be stored by ILIAS and used from then on each time the preview icon for that file is clicked on. If deactivated, previews will be generated anew each time. +previews_for_tiles#:#Tiles +previews_for_tiles_info#:#Use Preview for Tiles where possible. +publish_before_delete#:#It was not possible to delete any of the existing versions because an unpublished draft exists. +replace_file_info#:#All previous file versions will be deleted. +resource_id#:#Resource ID +service_settings#:#Additional Features +service_settings_saved#:#Changes saved. +set_license_for_all_files#:#Set License for All Files +show_amount_of_downloads#:#Show Number of Downloads +show_amount_of_downloads_info#:#Display the number of times a file object has been downloaded on its 'Info' page. +storage_id#:#Storage ID +suffix_specific_icons#:#Suffix-Specific Icons +suffixes#:#Suffix(es) +upload_files#:#Upload Files +upload_files_limit#:#The maximum file size allowed is %s. +upload_files_title#:#Upload Files +upload_info#:#File +upload_info_desc#:#Uploads and versions can be managed in the ‘Versions’ tab. +version_uploaded#:#Version Uploaded +versionname#:#Title \ No newline at end of file diff --git a/components/ILIAS/Init/Init.php b/components/ILIAS/Init/Init.php index a33d7222552d..4a26ba22810d 100644 --- a/components/ILIAS/Init/Init.php +++ b/components/ILIAS/Init/Init.php @@ -173,7 +173,16 @@ public function init( $use[Logging\Logger\LoggerFactoryInterface::class], $use[Logging\Logger\DefaultConfigLoggerFactoryInterface::class], $use[Logging\Config\ConfigInterface::class], - $use[\ILIAS\LTI\View::class] + $use[\ILIAS\LTI\View::class], + $pull[\ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager::class], + $pull[\ILIAS\Language\Activities\InstallLanguage::class], + $pull[\ILIAS\Language\Activities\UpdateLanguage::class], + $pull[\ILIAS\Language\Activities\UninstallLanguage::class], + $pull[\ILIAS\Language\Activities\RemoveLocalLanguageChanges::class], + $pull[\ILIAS\Language\Activities\AddLanguageEntry::class], + $pull[\ILIAS\Language\Activities\SetLanguageDetectionEnabled::class], + $pull[\ILIAS\Language\Activities\SetLanguageTranslationEnabled::class], + $use[\ILIAS\Language\Language::class], ); } } diff --git a/components/ILIAS/Init/src/AllModernComponents.php b/components/ILIAS/Init/src/AllModernComponents.php index e50f113ad099..44c165ed6487 100644 --- a/components/ILIAS/Init/src/AllModernComponents.php +++ b/components/ILIAS/Init/src/AllModernComponents.php @@ -144,7 +144,16 @@ public function __construct( protected Logging\Logger\LoggerFactoryInterface $logger_factory, protected Logging\Logger\DefaultConfigLoggerFactoryInterface $default_config_logger_factory, protected Logging\Config\ConfigInterface $logging_config, - protected \ILIAS\LTI\View $lti_view + protected \ILIAS\LTI\View $lti_view, + protected \ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager $language_file_directory_manager, + protected \ILIAS\Language\Activities\InstallLanguage $install_language, + protected \ILIAS\Language\Activities\UpdateLanguage $update_language, + protected \ILIAS\Language\Activities\UninstallLanguage $uninstall_language, + protected \ILIAS\Language\Activities\RemoveLocalLanguageChanges $remove_local_language_changes, + protected \ILIAS\Language\Activities\AddLanguageEntry $add_language_entry, + protected \ILIAS\Language\Activities\SetLanguageDetectionEnabled $set_language_detection_enabled, + protected \ILIAS\Language\Activities\SetLanguageTranslationEnabled $set_language_translation_enabled, + protected \ILIAS\Language\Language $language, ) { } @@ -257,6 +266,15 @@ protected function populateComponentsInLegacyEnvironment(\Pimple\Container $DIC) $DIC['logging.defaultConfigFactory'] = fn(): Logging\Logger\DefaultConfigLoggerFactoryInterface => $this->default_config_logger_factory; $DIC['logging.config'] = fn(): Logging\Config\ConfigInterface => $this->logging_config; $DIC['lti'] = fn(): \ILIAS\LTI\View => $this->lti_view; + $DIC[\ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager::class] = fn(): \ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager => $this->language_file_directory_manager; + $DIC[\ILIAS\Language\Activities\InstallLanguage::class] = fn(): \ILIAS\Language\Activities\InstallLanguage => $this->install_language; + $DIC[\ILIAS\Language\Activities\UpdateLanguage::class] = fn(): \ILIAS\Language\Activities\UpdateLanguage => $this->update_language; + $DIC[\ILIAS\Language\Activities\UninstallLanguage::class] = fn(): \ILIAS\Language\Activities\UninstallLanguage => $this->uninstall_language; + $DIC[\ILIAS\Language\Activities\RemoveLocalLanguageChanges::class] = fn(): \ILIAS\Language\Activities\RemoveLocalLanguageChanges => $this->remove_local_language_changes; + $DIC[\ILIAS\Language\Activities\AddLanguageEntry::class] = fn(): \ILIAS\Language\Activities\AddLanguageEntry => $this->add_language_entry; + $DIC[\ILIAS\Language\Activities\SetLanguageDetectionEnabled::class] = fn(): \ILIAS\Language\Activities\SetLanguageDetectionEnabled => $this->set_language_detection_enabled; + $DIC[\ILIAS\Language\Activities\SetLanguageTranslationEnabled::class] = fn(): \ILIAS\Language\Activities\SetLanguageTranslationEnabled => $this->set_language_translation_enabled; + $DIC[\ILIAS\Language\Language::class] = fn(): \ILIAS\Language\Language => $this->language; } public function getName(): string diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 2cbc888fc36a..83b4be1440c5 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -20,6 +20,21 @@ namespace ILIAS; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Activities\UninstallLanguage; +use ILIAS\Language\Activities\RemoveLocalLanguageChanges; +use ILIAS\Language\Activities\AddLanguageEntry; +use ILIAS\Language\Activities\SetLanguageDetectionEnabled; +use ILIAS\Language\Activities\SetLanguageTranslationEnabled; +use ILIAS\Language\Setup\InstalledLanguageRepository; +use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\LanguageInstallationManager; + class Language implements Component\Component { public function init( @@ -34,23 +49,275 @@ public function init( ): void { $define[] = \ILIAS\Language\Language::class; + // Shared by every $internal service below that needs the Setup/ + // installation database - deduplicated here instead of being + // reconstructed identically in each closure. + $resolve_db = static fn(): \ilDBInterface => $GLOBALS['ilDB'] ?? $GLOBALS['DIC']->database(); + $ilias_root = (string) realpath(__DIR__ . '/../../../'); + + // Shared by every Activity below that needs a "write" RBAC check on + // the language folder ref_id (all seven), or the system settings + // service (SetLanguageDetectionEnabled/SetLanguageTranslationEnabled) - + // deduplicated here for the same reason as $resolve_db/$ilias_root + // above. These stay Closures, not resolved values: init() runs at + // bootstrap-build time, without a database or a fully booted $DIC. + $rbac_system = static fn(): \ilRbacSystem => $GLOBALS['DIC']->rbac()->system(); + $lang_folder_ref_id = static fn(): int => \ilObjLanguageAccess::_lookupLangFolderRefId(); + $settings = static fn(): \ILIAS\Administration\Setting => $GLOBALS['DIC']->settings(); + + // --- $internal: wiring local to this component ----------------- + // Ordered so each entry's dependencies are declared above it. + + $internal[LanguageFileDirectoryManager::class] = static fn() => + new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + ...$seek[LanguageFileDirectory::class] + ); + + // Read (InstalledLanguageRepository) and write (LanguageInstallationManager) + // access to the language installation domain, extracted from + // ilSetupLanguage per docs/development/repository-pattern.md - see that + // class' docblock. ilSetupLanguage itself keeps delegating to both and + // remains the \ILIAS\Language\Language implementation used during Setup + // (see $implement[...] below); these two are for consumers that only + // need install/retrieval behaviour, not txt() - see $provide[...] below + // and components/ILIAS/Language/README.md. + $internal[InstalledLanguageDatabaseRepository::class] = static fn() => + new InstalledLanguageDatabaseRepository( + $resolve_db, + $internal[LanguageFileDirectoryManager::class], + $ilias_root + ); + + $internal[LanguageInstallationManager::class] = static fn() => + new LanguageInstallationManager( + $resolve_db, + $internal[LanguageFileDirectoryManager::class], + $ilias_root, + $internal[InstalledLanguageDatabaseRepository::class] + ); + + $internal[\ilSetupLanguage::class] = static fn() => + new \ilSetupLanguage( + "en", + $internal[LanguageFileDirectoryManager::class] + ); + + $internal[InstallLanguage::class] = static fn() => + new InstallLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + // The Activity needs no database of its own: every database + // access in perform() goes through ilSetupLanguage, which + // resolves it itself. + $internal[\ilSetupLanguage::class], + $lang_folder_ref_id + ); + + $internal[UpdateLanguage::class] = static fn() => + new UpdateLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + // Same reasoning as InstallLanguage above: no database of + // its own, ilSetupLanguage resolves it. + $internal[\ilSetupLanguage::class], + $lang_folder_ref_id + ); + + // Unlike InstallLanguage/UpdateLanguage, UninstallLanguage needs no + // ilSetupLanguage - uninstalling a language is a runtime-only action with no + // equivalent step during Setup - and so no forSetup() factory either; its two legacy + // collaborators (enumerating "lng" objects, constructing an ilObjLanguage by id) are + // left at their defaults, which resolve the exact same legacy calls the extracted GUI + // code used directly before. + $internal[UninstallLanguage::class] = static fn() => + new UninstallLanguage( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $lang_folder_ref_id + ); + + // Same reasoning as UninstallLanguage above: no Setup counterpart, no + // ilSetupLanguage, no forSetup() factory; its two legacy + // collaborators (enumerating "lng" objects, constructing an + // ilObjLanguage by id) are left at their defaults, which resolve the + // exact same legacy calls the extracted GUI code used directly + // before. + $internal[RemoveLocalLanguageChanges::class] = static fn() => + new RemoveLocalLanguageChanges( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $lang_folder_ref_id + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - adding a single language entry is a runtime-only action with no equivalent + // step during Setup. Unlike UninstallLanguage/RemoveLocalLanguageChanges, this needs no + // "lng" object enumeration or ilObjLanguage-by-id closures - it reads the set of + // installed languages from InstalledLanguageDatabaseRepository (already built above for + // other consumers) and writes single entries via the two legacy closures defaulted + // inside AddLanguageEntry itself; the second of those two (updateModuleCache()) needs a + // database connection, supplied here via the same $resolve_db closure every other + // database consumer in this component shares, rather than AddLanguageEntry reaching + // into $GLOBALS['DIC'] directly. + $internal[AddLanguageEntry::class] = static fn() => + new AddLanguageEntry( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $internal[InstalledLanguageDatabaseRepository::class], + // $db is a mandatory (non-nullable) collaborator - AddLanguageEntry needs no + // database of its own beyond what updateModuleCache()'s default resolves via + // this same $resolve_db closure, already shared by + // InstalledLanguageDatabaseRepository/LanguageInstallationManager. + $resolve_db, + language_folder_ref_id: $lang_folder_ref_id, + // replace_lang_entry/update_module_cache/user_login: left at + // their defaults (see AddLanguageEntry's own constructor + // docblock). + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - toggling a system setting is a runtime-only action with no equivalent step + // during Setup. Unlike every other Activity above, this needs no "lng" object + // enumeration, no ilObjLanguage-by-id closure and no installed-language repository - it + // only ever reads/writes the single "lang_detection" system setting, via the same + // $GLOBALS['DIC']->settings() call the extracted GUI code used directly (as + // $this->settings). + $internal[SetLanguageDetectionEnabled::class] = static fn() => + new SetLanguageDetectionEnabled( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $settings, + $lang_folder_ref_id + ); + + // Same reasoning again, minus the Setup/forSetup() bridge, which does not apply here + // either - toggling a per-language setting is a runtime-only action with no equivalent + // step during Setup. Like SetLanguageDetectionEnabled, this needs no "lng" object + // enumeration and no ilObjLanguage-by-id closure - it only ever reads/writes a single + // "lang_translate_" system setting, via the same $GLOBALS['DIC']->settings() call + // the extracted GUI code used directly (as $DIC->settings() inside + // saveSettingsObject()). Unlike SetLanguageDetectionEnabled, it now also needs the + // installed-language repository (already built above for AddLanguageEntry) to validate + // that the given language_key is actually installed. + $internal[SetLanguageTranslationEnabled::class] = static fn() => + new SetLanguageTranslationEnabled( + $pull[\ILIAS\Refinery\Factory::class], + $use[\ILIAS\Language\Language::class], + $rbac_system, + $settings, + $internal[InstalledLanguageDatabaseRepository::class], + language_folder_ref_id: $lang_folder_ref_id, + ); + + // LanguageLegacyInitialisationAdapter has no constructor of its own, + // so it never needs the LanguageFileDirectoryManager argument - + // it purely proxies to $DIC->language() at call time. This slot used + // to be misleadingly named $internal[\ilLanguage::class] even though + // it never held an \ilLanguage instance. + $internal[Language\LanguageLegacyInitialisationAdapter::class] = static fn() => + new Language\LanguageLegacyInitialisationAdapter(); + + // --- $implement -------------------------------------------------- + + // This component registers TWO candidate implementations for + // \ILIAS\Language\Language. Both assignments below are intentional - + // ILIAS\Component\Dependencies\Reader::cacheImplement() collects every + // $implement[...] assignment into a list rather than overwriting a + // plain array key, and the generated RenamingDIC keeps each candidate + // under its own offset, so neither line is dead code. + // Consumers that $use[\ILIAS\Language\Language::class] (e.g. UI.php, + // Setup.php, Refinery.php) are disambiguated per bootstrap entry point: + // - components/ILIAS/Setup/resources/dependency_resolution.php -> ilSetupLanguage + // - components/ILIAS/Init/resources/dependency_resolution.php -> LanguageLegacyInitialisationAdapter + // See docs/development/components-and-directories.md and cli/build_bootstrap.php. $implement[\ILIAS\Language\Language::class] = static fn() => $internal[\ilSetupLanguage::class]; $implement[\ILIAS\Language\Language::class] = static fn() => - $internal[\ilLanguage::class]; + $internal[Language\LanguageLegacyInitialisationAdapter::class]; + + // --- $provide: services made available to the rest of the system - + + // Make the resolved language services available outside this component. + $provide[LanguageFileDirectoryManager::class] = static fn() => + $internal[LanguageFileDirectoryManager::class]; + + $provide[InstalledLanguageRepository::class] = static fn() => + $internal[InstalledLanguageDatabaseRepository::class]; + + $provide[LanguageInstallationManager::class] = static fn() => + $internal[LanguageInstallationManager::class]; + + // --- $contribute: contributions to other components' collection + // points. Relative order preserved from before this file's + // reorganisation, since components may in general add multiple + // contributions to the same collection point sequentially (see + // docs/development/components-and-directories.md, "Contribute to + // Service or Functionality") - which the seven + // \ILIAS\Component\Activities\Activity::class entries below + // actually do, one per Activity this component offers. + + $contribute[LanguageFileDirectory::class] = static fn() => new MainLanguageFileDirectory(); $contribute[\ILIAS\Setup\Agent::class] = static fn() => new \ilLanguageSetupAgent( $pull[\ILIAS\Refinery\Factory::class], - $internal[\ilSetupLanguage::class] + $internal[\ilSetupLanguage::class], + $internal[InstallLanguage::class], + $internal[UpdateLanguage::class], + $internal[InstalledLanguageDatabaseRepository::class] ); - $internal[\ilSetupLanguage::class] = static fn() => - new \ilSetupLanguage("en"); - - $internal[\ilLanguage::class] = static fn() => - new Language\LanguageLegacyInitialisationAdapter(); + // The seven Activities this component offers are both provided under + // their own concrete class (so components/ILIAS/Init/Init.php can + // pull each one specifically - AllModernComponents.php then + // re-exposes that same resolved instance under the matching legacy + // $DIC[::class] key, which is what every GUI class in + // this component reads) and contributed generically to + // \ILIAS\Component\Activities\Activity::class (so the + // cross-component Repository can discover them too) - see + // docs/development/components-and-directories.md, "Contribute to + // Service or Functionality". InstallLanguage/UpdateLanguage are + // additionally handed to Setup Objectives directly, via + // $contribute[\ILIAS\Setup\Agent::class] above, or via their own + // forSetup() factory for callers not wired through the component + // graph; because Init.php pulls these concrete classes (not an + // interface), none of the seven may become final - both + // ilObjLanguageFolderGUITest and ilObjLanguageExtGUITest build a + // PHPUnit mock of several of them directly (createMock(InstallLanguage::class) + // and siblings), which requires PHPUnit to generate a subclass at + // runtime - impossible for a final class. + // + // Kept as one combined loop rather than seven repeated + // $provide[...]/$contribute[...] pairs. Order matters for the + // $contribute side only (RenamingDIC assigns a shared, container-wide + // "_" suffix in call order to disambiguate the repeated + // \ILIAS\Component\Activities\Activity::class key - see + // LanguageComponentGraphTest::testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder()); + // this list's order must therefore match $activities' declaration + // order exactly, and this loop must stay exactly here: after the two + // preceding $contribute entries (LanguageFileDirectory, Setup\Agent) + // and before the following one (UserSettings). + $activities = [ + InstallLanguage::class, + UpdateLanguage::class, + UninstallLanguage::class, + RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, + SetLanguageDetectionEnabled::class, + SetLanguageTranslationEnabled::class, + ]; + foreach ($activities as $activity) { + $provide[$activity] = static fn() => $internal[$activity]; + $contribute[\ILIAS\Component\Activities\Activity::class] = static fn() => $internal[$activity]; + } $contribute[User\Settings\UserSettings::class] = fn() => new Language\UserSettings\Settings(); diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 9a60a158bb43..e8f1a7627c1a 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -6,22 +6,122 @@ Language Service # General Information ## Guidelines -There are a couple of guidelines defined in the [language.md](../../docs/development/language.md) that have to be respected when adding language variables to the lang files of ILIAS or editing them. +There are a couple of guidelines defined in the [language.md](../../../docs/development/language.md) that have to be respected when adding language variables to the lang files of ILIAS or editing them. -## Using the Global Language Object -The global language object can be retrieved from the dependency injection container by using/calling `$DIC['lng']` or -`$DIC->language()`. This is an instance of class `ilLanguage` and provides methods to access these strings in the -language of the user within the current authentication process. This is done by using the functions - `loadLanguageModule()` and `txt()`. +## Using the Language Service +Component-revision code should depend on `ILIAS\Language\Language`. During the legacy bootstrap this interface is +provided lazily, so early component construction does not access the language service before the legacy container has +been initialised. After initialisation it delegates to the active `ilLanguage` runtime instance. - $lng->loadLanguageModule("frm"); - $tpl->setVariable("TEXT", $lng->txt("frm_new_posting")); +For components which have not yet been migrated, the active runtime language remains available through `$DIC['lng']` +or `$DIC->language()`. These access paths are a temporary compatibility layer and must not be used for new code. +Setup code has a separate language implementation, `ilSetupLanguage`, which is constructed without runtime user, +session, or container state. It must not be used as the runtime language service. + + $language->loadLanguageModule("frm"); + $tpl->setVariable("TEXT", $language->txt("frm_new_posting")); + +## Installing and Managing Languages +Which languages are installed/available (`ILIAS\Language\Setup\InstalledLanguageRepository`) and installing, flushing +or registering a language (`ILIAS\Language\Setup\LanguageInstallationManager`) are separate, narrower services - +following [the repository pattern](../../../docs/development/repository-pattern.md) - rather than part of +`ilSetupLanguage` itself. New code that only needs to install or inspect languages should depend on these two +instead of on `ilSetupLanguage`, which still exists (delegating to both) as the concrete `ILIAS\Language\Language` +implementation used during Setup and as a stable entry point for callers not yet wired through `Language.php`. + + +## Language File Directories +Where language files are looked up - the global `lang` directories, a component's own language +files, and the Customizing overrides - is resolved through `ILIAS\Language\ComponentTranslation\*` +(`src/ComponentTranslation/`): `LanguageFileDirectory` is the interface each source implements +(`MainLanguageFileDirectory`, `ComponentLanguageFileDirectory`, `CustomizingLanguageFileDirectory`), +and `LanguageFileDirectoryManager` aggregates all of them, contributed by other components via +`$contribute[LanguageFileDirectory::class]` and gathered in `Language.php` via +`$seek[LanguageFileDirectory::class]`. `InstalledLanguageDatabaseRepository` and +`LanguageInstallationManager` both depend on this manager rather than hardcoding paths. + +## User Settings Contribution +This component contributes a personal "language" setting to the user settings framework +(`ILIAS\Language\UserSettings\Settings`, wired in `Language.php` via +`$contribute[User\Settings\UserSettings::class]`). + +## Activities +This component provides seven [Activities](../Component/src/Activities/README.md) +(`ILIAS\Language\Activities\*`), each wired into `Language.php` (`$internal`/`$provide`/ +`$contribute`) and used by this component's own GUI classes via `maybePerformAs()`. All seven +are `Command`s (they change installation/configuration state; none of them queries data without +side effects). See each class's own `getDescription()` for its authoritative, up-to-date +description rather than a copy here, which would drift out of sync: + +* **InstallLanguage** (`src/Activities/InstallLanguage.php`) - installs/re-applies one or more + languages, depending on the chosen mode. +* **UpdateLanguage** (`src/Activities/UpdateLanguage.php`) - refreshes one or more already + installed languages from the current language files. +* **UninstallLanguage** (`src/Activities/UninstallLanguage.php`) - uninstalls one or more already + installed languages. +* **RemoveLocalLanguageChanges** (`src/Activities/RemoveLocalLanguageChanges.php`) - removes all + local changes of one or more already installed languages and reinstalls them from the + global/component language files. +* **AddLanguageEntry** (`src/Activities/AddLanguageEntry.php`) - adds one new "adjust language + variables" entry to every currently installed language for which a value was given. +* **SetLanguageDetectionEnabled** (`src/Activities/SetLanguageDetectionEnabled.php`) - enables or + disables the system-wide automatic language detection from the browser's Accept-Language + header. +* **SetLanguageTranslationEnabled** (`src/Activities/SetLanguageTranslationEnabled.php`) - enables + or disables the "page translation" feature for one specific language. + +### Known, accepted deviations from the Activity contract + +* **AddLanguageEntry::perform()** additionally accepts an optional `usr_id` (int) key in its + `$parameters`, even though `getInputDescription()`'s `FormInput` never produces one - it is + supplied out-of-band by `maybePerformAs()` (never spoofable via form data) and recorded as the + author of the local change made to every written entry. A caller that omits it (e.g. a generic + caller following the plain `getInputDescription()` -> `withInput()` -> `getContent()` -> + `perform()` contract) is not rejected; the entries are simply written without an attributed + author. +* **AddLanguageEntry::getInputDescription()** builds per-language field labels via + `$this->lng->txt('meta_l_' . $lang_key)`, which requires the caller to have already called + `loadLanguageModule('meta')` beforehand. This component's own GUI does so already (see + `classes/class.ilObjLanguageExtGUI.php`'s constructor). A caller that skips this gets the raw, + untranslated placeholder (e.g. `-meta_l_de-`) as the label instead of a crash. `getInputDescription()` + deliberately does not load the module itself, since `loadLanguageModule()` merges the module's + keys, unnamespaced, into the shared `$this->lng` state - doing so implicitly here could clobber + keys for an unrelated module if a future generic caller iterates over several Activities sharing + one `Language`/`ilLanguage` instance. +* **AddLanguageEntry::getInputDescription()** resolves the current set of installed languages from + `InstalledLanguageRepository::getInstalledLanguages()` on every call, rather than being a pure, + static description of the input shape. Within one `maybePerformAs()` call, `getInputDescription()` + and `perform()` can therefore observe different snapshots if a language is installed/uninstalled + concurrently between the two calls: a newly installed optional language simply ends up in + `skipped_empty_language_keys`; a newly installed `de`/`en` makes `perform()` reject the whole + request (fail-closed, since no value could have been submitted for it); a language uninstalled + in the meantime has its submitted value silently dropped rather than written or reported. No + case crashes or writes partial data. ## Supported HTML Tags in Language Files -Only a defined set of HTML tags are allowed to be used within the `text_content` of a language entry: +Only a defined set of HTML tags are allowed to be used within the `text_content` of a language entry. +The allow-list is passed explicitly to `ilUtil::stripSlashes()` at the only place this component +applies it - the language file import in `ilObjLanguageExtGUI::importLangfile()` +(`classes/class.ilObjLanguageExtGUI.php`) - and currently is: -* All tags allowed by `getSecureTags` from `ilUtil`: `a`, `b`, `bdo`, `code`, `div`, `em`, `gap`, `i`, `img`, `li`, `ol`, `p`, `pre`, `strike`, `strong`, `sub`, `sup`, `u` and `ul` -* In addition: `span` and `br` +* `a`, `b`, `bdo`, `br`, `code`, `div`, `em`, `gap`, `i`, `img`, `li`, `ol`, `p`, `pre`, `span`, + `strike`, `strong`, `sup` and `ul` + +This is almost, but not exactly, the tag set returned by `ilUtil::getSecureTags()` plus `span` and +`br`: `sub` is part of `getSecureTags()` but is missing from the allow-list actually passed here. +Whether that omission is intentional is unclear; treat `sub` as unsupported in language files until +this is resolved one way or the other. All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes`. + +## Further Reading +* [use-language-object.md](use-language-object.md) and [use-language-logging.md](use-language-logging.md) + document the older, still widely used `$lng`/`ilLanguage` access pattern. They predate the + `ILIAS\Language\Language` interface and the Activities described above and have not been updated + to reference them; prefer the "Using the Language Service" section above for new, + component-revision code. +* [`tests/LanguageComponentGraphTest.php`](tests/LanguageComponentGraphTest.php) is a contract test + for this component's `$define`/`$implement`/`$use`/`$contribute`/`$seek`/`$provide`/`$pull`/`$internal` + wiring in `Language.php` and is a good place to check the current, authoritative wiring behaviour + (e.g. singleton guarantees, contribute ordering) rather than relying on prose descriptions here. diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md index 1e394d3d2505..c93696bb6a67 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -4,17 +4,29 @@ * Accelerated language update (no more need to introduce background tasks) * Removed language installation in config (only English is installed by Setup) -* Substituted LegacyUI Button by KS Button +* Substituted LegacyUI Button by KS Button in the folder GUI's toolbar (`ilObjLanguageFolderGUI`); + the form action buttons in `ilObjLanguageExtGUI` (`addCommandButton()`) are still legacy `ilPropertyFormGUI` buttons +* Migrated the component to the Component Revision's dependency graph + (`$define`/`$implement`/`$use`/`$contribute`/`$seek`/`$provide`/`$pull`/`$internal` in `Language.php`) + and introduced the Activities pattern: seven `ILIAS\Language\Activities\*` classes now hold this + component's install/update/uninstall/detection/translation-toggle/entry-editing logic, used by the + GUI classes via `maybePerformAs()` - see the README's "Activities" section +* Extracted language-installation state access into `InstalledLanguageRepository` and + `LanguageInstallationManager` (repository pattern), and language-file-directory resolution into + `ILIAS\Language\ComponentTranslation\*` - see the README ## Short Term * Fixing PHP 8.2 issues * Analysing use of language variables on test9 * GitHook for preventing duplicate use of same variable_ID in language files +* Migrate the remaining `addCommandButton()` form buttons in `ilObjLanguageExtGUI` to KS Button ## Mid Term -* Move LegacyUI Table2GUI to KS Data Table +* Move LegacyUI Table2GUI to KS Data Table - `ilLanguageFolderTable` and `ilLanguageStatisticsTable` + already implement the KS `DataRetrieval` table; only `ilLanguageExtTableGUI` (still `extends ilTable2GUI`, + used by `ilObjLanguageExtGUI::getViewTable()`) remains to be migrated * Remove unused language variables from language files * Improving export and import of customised language files * Improving online translation tool diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php index c97bc0bd8b45..16dd975a451b 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageMetricsCollectedObjective.php @@ -19,17 +19,18 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\Setup\InstalledLanguageRepository; class ilLanguageMetricsCollectedObjective extends Setup\Metrics\CollectedObjective { - protected \ilSetupLanguage $il_setup_language; + protected InstalledLanguageRepository $repository; public function __construct( Setup\Metrics\Storage $storage, - \ilSetupLanguage $il_setup_language + InstalledLanguageRepository $repository ) { parent::__construct($storage); - $this->il_setup_language = $il_setup_language; + $this->repository = $repository; } /** @@ -61,15 +62,16 @@ protected function collectFrom(Setup\Environment $environment, Setup\Metrics\Sto if (!($db instanceof \ilDBInterface)) { return; } - $this->il_setup_language->setDbHandler($db); - // TODO: Remove this once ilSetupLanguage (or a successor) supports proper - // DI for all methods. + // InstalledLanguageRepository resolves its database lazily via + // $GLOBALS['ilDB'] ?? $DIC->database(), so it picks up the + // Setup-provided database resource as soon as it is assigned here - + // see components/ILIAS/Language/Language.php. $GLOBALS["ilDB"] = $db; $installed_languages = []; - $local_languages = $this->il_setup_language->getLocalLanguages(); - foreach ($this->il_setup_language->getInstalledLanguages() as $lang) { + $local_languages = $this->repository->getLocalLanguages(); + foreach ($this->repository->getInstalledLanguages() as $lang) { $local_file = new Setup\Metrics\Metric( Setup\Metrics\Metric::STABILITY_STABLE, Setup\Metrics\Metric::TYPE_BOOL, @@ -79,7 +81,7 @@ protected function collectFrom(Setup\Environment $environment, Setup\Metrics\Sto $local_changes = new Setup\Metrics\Metric( Setup\Metrics\Metric::STABILITY_STABLE, Setup\Metrics\Metric::TYPE_BOOL, - count($this->il_setup_language->getLocalChanges($lang)) > 0, + count($this->repository->getLocalChanges($lang)) > 0, "Are there local changes for the language?" ); $installed_languages[$lang] = new Setup\Metrics\Metric( diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php index a1e0c1030791..4d3e3d67100e 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageObjective.php @@ -20,6 +20,13 @@ use ILIAS\Setup; +/** + * Not every Objective in this component installs/updates languages (e.g. + * ilDefaultLanguageSetObjective does not), so the "install languages via + * ilSetupLanguage/InstallLanguage" dependencies and helper are not on this + * common base - see ilLanguagesInstalledAndUpdatedObjective, the only + * Objective that needs them. + */ abstract class ilLanguageObjective implements Setup\Objective { public function __construct() diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php index fbda65bbce47..0d5d273ee162 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguageSetupAgent.php @@ -21,6 +21,9 @@ use ILIAS\Setup; use ILIAS\Refinery; use ILIAS\UI; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Setup\InstalledLanguageRepository; class ilLanguageSetupAgent implements Setup\Agent { @@ -28,13 +31,22 @@ class ilLanguageSetupAgent implements Setup\Agent protected Refinery\Factory $refinery; protected \ilSetupLanguage $il_setup_language; + protected InstallLanguage $install_language; + protected UpdateLanguage $update_language; + protected InstalledLanguageRepository $repository; public function __construct( Refinery\Factory $refinery, - \ilSetupLanguage $il_setup_language + \ilSetupLanguage $il_setup_language, + InstallLanguage $install_language, + UpdateLanguage $update_language, + InstalledLanguageRepository $repository ) { $this->refinery = $refinery; $this->il_setup_language = $il_setup_language; + $this->install_language = $install_language; + $this->update_language = $update_language; + $this->repository = $repository; } /** @@ -61,7 +73,11 @@ public function getInstallObjective(?Setup\Config $config = null): Setup\Objecti return new Setup\ObjectiveCollection( "Complete objectives from components/ILIAS/Language", false, - new ilLanguagesInstalledAndUpdatedObjective($this->il_setup_language), + new ilLanguagesInstalledAndUpdatedObjective( + $this->il_setup_language, + $this->install_language, + $this->update_language + ), new ilDefaultLanguageSetObjective() ); } @@ -74,7 +90,11 @@ public function getUpdateObjective(?Setup\Config $config = null): Setup\Objectiv return new Setup\ObjectiveCollection( "Complete objectives from components/ILIAS/Language", false, - new ilLanguagesInstalledAndUpdatedObjective($this->il_setup_language), + new ilLanguagesInstalledAndUpdatedObjective( + $this->il_setup_language, + $this->install_language, + $this->update_language + ), ); } @@ -91,7 +111,7 @@ public function getBuildObjective(): Setup\Objective */ public function getStatusObjective(Setup\Metrics\Storage $storage): Setup\Objective { - return new ilLanguageMetricsCollectedObjective($storage, $this->il_setup_language); + return new ilLanguageMetricsCollectedObjective($storage, $this->repository); } /** diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php index f85796340a43..ecb8a75fe499 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php +++ b/components/ILIAS/Language/classes/Setup/class.ilLanguagesInstalledAndUpdatedObjective.php @@ -19,16 +19,40 @@ declare(strict_types=1); use ILIAS\Setup; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; class ilLanguagesInstalledAndUpdatedObjective extends ilLanguageObjective { protected \ilSetupLanguage $il_setup_language; + protected InstallLanguage $install_language; + protected UpdateLanguage $update_language; + /** + * @param InstallLanguage|null $install_language Callers wired through + * the component graph (see ilLanguageSetupAgent) pass the + * resolved instance. Callers that are themselves constructed + * deep inside the Setup Objective tree - + * ilComponentPluginAdminInitObjective and + * ilPluginLanguageUpdatedObjective, reached via + * ilPluginDefaultAgent, which only ever receives a plugin name - + * have nothing to inject and omit it; a Setup-only instance is + * then built here (InstallLanguage::forSetup(), which needs the + * concrete class, not just any Activity). This is the single + * place that knows how, instead of each of those call sites + * reaching into $GLOBALS['DIC'] for a key that Setup never + * registers. + * @param UpdateLanguage|null $update_language Same reasoning as + * $install_language above. + */ public function __construct( - \ilSetupLanguage $il_setup_language + \ilSetupLanguage $il_setup_language, + ?InstallLanguage $install_language = null, + ?UpdateLanguage $update_language = null ) { - parent::__construct(); $this->il_setup_language = $il_setup_language; + $this->install_language = $install_language ?? InstallLanguage::forSetup($il_setup_language); + $this->update_language = $update_language ?? UpdateLanguage::forSetup($il_setup_language); } /** @@ -47,14 +71,6 @@ protected function getInstallLanguages(): array return $this->il_setup_language->getInstalledLanguages() ?: ['en']; } - /** - * Return installed local languages - */ - protected function getInstallLocalLanguages(): array - { - return $this->il_setup_language->getInstalledLocalLanguages(); - } - /** * @inheritDoc */ @@ -86,20 +102,9 @@ public function getPreconditions(Setup\Environment $environment): array */ public function achieve(Setup\Environment $environment): Setup\Environment { - $db = $environment->getResource(Setup\Environment::RESOURCE_DATABASE); - - // TODO: Remove this once ilSetupLanguage (or a successor) supports proper - // DI for all methods. - $db_tmp = $GLOBALS["ilDB"]; - $GLOBALS["ilDB"] = $db; - - $this->il_setup_language->setDbHandler($db); - $this->il_setup_language->installLanguages( - $this->getInstallLanguages(), - $this->getInstallLocalLanguages() - ); - - $GLOBALS["ilDB"] = $db_tmp; + // Must come first: getInstallLanguages() reads from the database too. + $this->useSetupDatabase($environment); + $this->installLanguages($this->getInstallLanguages()); return $environment; } @@ -111,4 +116,61 @@ public function isApplicable(Setup\Environment $environment): bool { return true; } + + /** + * Hands the Setup-provided database resource to ilSetupLanguage, which + * is the single place resolving it for everything that follows: the + * Activity needs no database of its own, and ilSetupLanguage's + * repository and manager re-read it on every call. + * + * achieve() must call this before reading anything from + * ilSetupLanguage, so that the language list is determined against the + * Setup database too. Overwriting $GLOBALS['ilDB'] around the install - + * which this used to do, with a TODO attached - is no longer necessary. + */ + protected function useSetupDatabase(Setup\Environment $environment): void + { + $this->il_setup_language->setDbHandler( + $environment->getResource(Setup\Environment::RESOURCE_DATABASE) + ); + } + + /** + * Installs every given key that is not yet installed, and refreshes + * every given key that already is - unconditionally, this Objective's + * job (label "Install/Update languages") is to keep already-installed + * languages in sync with the current language files on every setup.php + * run (e.g. after a core update added or changed translations), not + * just to install missing ones. + * + * Both Activities are handed the same full $language_keys list and each + * decides for itself what applies to it: InstallLanguage::MODE_INSTALL + * installs a not-yet-installed key and is a no-op for an already + * installed one; UpdateLanguage refreshes an already installed key and + * is a no-op for one that is not installed. Neither call needs the + * caller to pre-filter by install status. Running UpdateLanguage on the + * keys InstallLanguage just installed is intentional, not wasted work + * avoided: it keeps this method's behaviour simple (one full list, two + * independent Activities) and the freshly installed keys are also + * freshly refreshed, which is harmless. + * + * Each Activity validates and throws \RuntimeException only for the + * keys it actually processes (see InstallLanguage::perform() and + * UpdateLanguage::perform()); a validation error in the first call + * prevents the second from running at all, instead of collecting + * invalid keys from both into a single combined error. + * + * @param list $language_keys + */ + protected function installLanguages(array $language_keys): void + { + $this->install_language->perform([ + 'language_keys' => $language_keys, + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->update_language->perform([ + 'language_keys' => $language_keys, + ]); + } } diff --git a/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php b/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php deleted file mode 100755 index 629698eaa50b..000000000000 --- a/components/ILIAS/Language/classes/Setup/class.ilLanguagesUpdatedObjective.php +++ /dev/null @@ -1,103 +0,0 @@ -il_setup_language = $il_setup_language; - } - - /** - * @inheritDoc - */ - public function getHash(): string - { - return hash("sha256", self::class); - } - - /** - * Return installed languages as string - */ - protected function getInstalledLanguagesAsString(): string - { - return implode(", ", $this->il_setup_language->getInstalledLanguages()); - } - - /** - * @inheritDoc - */ - public function getLabel(): string - { - return "Update languages " . $this->getInstalledLanguagesAsString(); - } - - /** - * @inheritDoc - */ - public function isNotable(): bool - { - return true; - } - - /** - * @inheritDoc - */ - public function getPreconditions(Setup\Environment $environment): array - { - return []; - } - - /** - * @inheritDoc - */ - public function achieve(Setup\Environment $environment): Setup\Environment - { - $db = $environment->getResource(Setup\Environment::RESOURCE_DATABASE); - - // TODO: Remove this once ilSetupLanguage (or a successor) supports proper - //// DI for all methods. - $db_tmp = $GLOBALS["ilDB"]; - $GLOBALS["ilDB"] = $db; - - $this->il_setup_language->setDbHandler($db); - $this->il_setup_language->installLanguages( - $this->il_setup_language->getInstalledLanguages(), - $this->il_setup_language->getLocalLanguages() - ); - - $GLOBALS["ilDB"] = $db_tmp; - - return $environment; - } - - /** - * @inheritDoc - */ - public function isApplicable(Setup\Environment $environment): bool - { - return true; - } -} diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index eb7fc44c3056..d9a9c886be44 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -18,6 +18,14 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\ComponentLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\Setup\InstalledLanguageRepository; +use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\LanguageInstallationManager; + /** * language handling for setup * @@ -32,29 +40,65 @@ * @author Peter Gabriel * @version $Id$ * - * - * @todo The DATE field is not set correctly on changes of a language (update, install, your stable). - * The format functions do not belong in class.Language. Those are also applicable elsewhere. - * Therefore, they would be better placed in class.Format - * @todo This somehow needs to be reconciled with the base class and most probably be factored - * into two classes, one for management, one for retrieval. + * This class has two responsibilities that used to be more entangled than + * they are now: + * - being a \ILIAS\Language\Language implementation (via ilLanguage), used + * as the txt()-lookup during Setup, before the full runtime DIC exists, + * - installing/managing languages in the database and validating language + * files on disk. + * The latter responsibility has been extracted into + * \ILIAS\Language\Setup\InstalledLanguageRepository (read access) and + * \ILIAS\Language\Setup\LanguageInstallationManager (write access), per + * docs/development/repository-pattern.md. This class now mostly delegates + * to both and is kept around as the concrete \ILIAS\Language\Language + * implementation for Setup and as a stable entry point for the many + * existing callers (Setup Objectives, ilObjLanguage, ...) that are not + * (yet) wired through components/ILIAS/Language/Language.php. */ class ilSetupLanguage extends ilLanguage { + private string $absolute_path; public array $text; public string $lang_default = "en"; - public string $lang_path; public string $lang_key; public string $separator = "#:#"; public string $comment_separator = "###"; - protected ilDBInterface $db; - - public function __construct(string $a_lang_key) - { + protected ?ilDBInterface $db = null; + private readonly InstalledLanguageRepository $repository; + private readonly LanguageInstallationManager $manager; + + public function __construct( + string $a_lang_key, + private ?LanguageFileDirectoryManager $language_file_directory_manager = null, + ) { $this->lang_key = $a_lang_key ?: $this->lang_default; - $il_absolute_path = realpath(__DIR__ . "/../../../../../"); - $this->lang_path = $il_absolute_path . "/lang"; - $this->cust_lang_path = $il_absolute_path . "/lang/customizing"; + $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); + $this->language_file_directory_manager = $language_file_directory_manager ?? new LanguageFileDirectoryManager( + new CustomizingLanguageFileDirectory(), + new MainLanguageFileDirectory() + ); + $this->cust_lang_path = $this->absolute_path . "/lang/customizing"; + $this->lang_path = $this->absolute_path . "/lang"; + + // Resolved lazily and re-read on every call, so a database handed in + // later via setDbHandler() actually takes effect - which is what lets + // Setup Objectives inject the Setup-provided database instead of + // temporarily overwriting $GLOBALS['ilDB'] around the call (see + // ilLanguagesInstalledAndUpdatedObjective::useSetupDatabase()). The + // global remains the fallback for the runtime, where + // ilInitialisation::initDatabase() populates it via initGlobal(). + $db_resolver = fn(): ilDBInterface => $this->db ?? $GLOBALS["ilDB"]; + $this->repository = new InstalledLanguageDatabaseRepository( + $db_resolver, + $this->language_file_directory_manager, + $this->absolute_path + ); + $this->manager = new LanguageInstallationManager( + $db_resolver, + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository + ); } /** @@ -97,254 +141,111 @@ public function txt(string $a_topic, string $a_default_lang_fallback_mod = ''): * * @return array|bool */ - public function installLanguages(array $a_lang_keys, array $a_local_keys) + public function installLanguages(array $a_lang_keys, array $a_local_keys = []) { - global $ilDB; - if (empty($a_lang_keys)) { - $a_lang_keys = array(); - } - - if (empty($a_local_keys)) { - $a_local_keys = array(); - } - - $err_lang = array(); - - $db_langs = $this->getAvailableLanguages(); - - foreach ($a_lang_keys as $lang_key) { - if ($this->checkLanguage($lang_key)) { - $this->flushLanguage($lang_key, "keep_local"); - $this->insertLanguage($lang_key); - - if (in_array($lang_key, $a_local_keys, true) && is_dir($this->cust_lang_path)) { - if ($this->checkLanguage($lang_key, "local")) { - $this->insertLanguage($lang_key, "local"); - } else { - $err_lang[] = $lang_key; - } - } - - // register language first time install - if (!array_key_exists($lang_key, $db_langs)) { - if (in_array($lang_key, $a_local_keys, true)) { - $itype = "installed_local"; - } else { - $itype = "installed"; - } - $lid = $ilDB->nextId("object_data"); - $query = "INSERT INTO object_data " . - "(obj_id,type,title,description,owner,create_date,last_update) " . - "VALUES " . - "(" . - $ilDB->quote($lid, "integer") . "," . - $ilDB->quote("lng", "text") . "," . - $ilDB->quote($lang_key, "text") . "," . - $ilDB->quote($itype, "text") . "," . - $ilDB->quote("-1", "integer") . "," . - $ilDB->now() . "," . - $ilDB->now() . - ")"; - $ilDB->manipulate($query); - } - } else { - $err_lang[] = $lang_key; - } - } - - foreach ($db_langs as $key => $val) { - if (!in_array($key, $err_lang, true)) { - if (in_array($key, $a_lang_keys, true)) { - if (in_array($key, $a_local_keys, true)) { - $ld = "installed_local"; - } else { - $ld = "installed"; - } - $query = "UPDATE object_data SET " . - "description = " . $ilDB->quote($ld, "text") . ", " . - "last_update = " . $ilDB->quote(gmdate("Y-m-d H:i:s"), "timestamp") . " " . - "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " . - "AND type = " . $ilDB->quote("lng", "text"); - $ilDB->manipulate($query); - } else { - $this->flushLanguage($key, "all"); - - if (strpos($val["status"], "installed") === 0) { - $query = "UPDATE object_data SET " . - "description = " . $ilDB->quote("not_installed", "text") . ", " . - "last_update = " . $ilDB->quote(gmdate("Y-m-d H:i:s"), "timestamp") . " " . - "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " . - "AND type = " . $ilDB->quote("lng", "text"); - $ilDB->manipulate($query); - } - } - } + $a_lang_keys = []; } - return ($err_lang) ?: true; + return $this->manager->installLanguages($a_lang_keys, $a_local_keys); } /** - * get already installed languages (in db) + * Registers or updates the object_data bookkeeping row for a language + * that has just been flushed/(re-)inserted, deciding "installed" vs. + * "installed_local" and INSERTing a fresh row or UPDATEing the existing + * one accordingly. This is the single source of truth for that + * bookkeeping - both installLanguages() and + * \ILIAS\Language\Activities\InstallLanguage::perform() call this + * instead of duplicating the object_data INSERT/UPDATE. + * + * @param array $known_languages result of getAvailableLanguages()/getAvailableLanguagesForInstallation() + * @param list $local_language_keys result of getLocalLanguages() */ - public function getInstalledLanguages(): array - { - global $ilDB; + public function registerInstalledLanguage( + string $lang_key, + array $known_languages, + array $local_language_keys + ): void { + $this->manager->registerInstalledLanguage($lang_key, $known_languages, $local_language_keys); + } - $arr = []; - if ($ilDB instanceof ilDBInterface) { - $query = "SELECT * FROM object_data " . - "WHERE type = " . $ilDB->quote("lng", "text") . " " . - "AND " . $ilDB->like("description", "text", "installed%"); - $r = $ilDB->query($query); + public function getAvailableLanguagesForInstallation(): array + { + return $this->repository->getAvailableLanguages(); + } - while ($row = $ilDB->fetchObject($r)) { - $arr[] = $row->title; - } - } - return $arr; + public function checkLanguageForInstallation(string $lang_key): bool + { + return $this->repository->checkLanguage($lang_key); } /** - * get already installed local languages (in db) + * Validates only the customizing/local language file for $lang_key + * (unlike checkLanguageForInstallation(), which treats a missing local + * file as acceptable). Used when a caller explicitly wants to validate + * a local customization file, e.g. before activating "installed_local" + * status for an already globally-installed language. */ - public function getInstalledLocalLanguages(): array + public function checkLocalLanguageFileForInstallation(string $lang_key): bool { - global $ilDB; + return $this->repository->checkLocalLanguageFile($lang_key); + } - $arr = []; - if ($ilDB instanceof ilDBInterface) { - $query = "SELECT * FROM object_data " . - "WHERE type = " . $ilDB->quote("lng", "text") . " " . - "AND description = " . $ilDB->quote("installed_local", "text"); - $r = $ilDB->query($query); + public function flushLanguageForInstallation(string $lang_key): void + { + $this->manager->flushLanguageForInstallation($lang_key); + } - while ($row = $ilDB->fetchObject($r)) { - $arr[] = $row->title; - } - } - return $arr; + public function flushLanguageForUninstallation(string $lang_key): void + { + $this->manager->flushLanguageForUninstallation($lang_key); + } + + public function insertLanguageForInstallation(string $lang_key): void + { + $this->manager->insertLanguageForInstallation($lang_key); } /** - * get already registered languages (in db) + * (Re-)apply only the customizing/local language file for an already + * installed language, without touching the base/global data - see + * LanguageInstallationManager::insertLanguageForApplyingLocalChanges() + * for why. */ - protected function getAvailableLanguages(): array + public function insertLanguageForApplyingLocalChanges(string $lang_key): void { - global $ilDB; - - $arr = array(); - - $query = "SELECT * FROM object_data " . - "WHERE type = " . $ilDB->quote("lng", "text"); - $r = $ilDB->query($query); - - while ($row = $ilDB->fetchObject($r)) { - $arr[$row->title]["obj_id"] = $row->obj_id; - $arr[$row->title]["status"] = $row->description; - } - - return $arr; + $this->manager->insertLanguageForApplyingLocalChanges($lang_key); } /** - * validate the logical structure of a lang-file - * - * This function checks if a lang-file of a given lang_key exists, - * the file has a header, and each lang-entry consists of exactly - * three elements (module, identifier, value). - * - * $a_lang_key international language key (2 digits) - * $scope empty (global) or "local" - * $info_text message about results of check OR "1" if all checks successfully passed + * get already installed languages (in db) */ - protected function checkLanguage(string $a_lang_key, string $scope = ""): bool + public function getInstalledLanguages(): array { - $scopeExtension = ""; - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } - } - - $path = $this->lang_path; - if ($scope === "local") { - $path = $this->cust_lang_path; - } - - $tmpPath = getcwd(); - chdir($path); - - // compute lang-file name format - $lang_file = "ilias_" . $a_lang_key . ".lang" . $scopeExtension; - - // file check - if (!is_file($lang_file)) { - chdir($tmpPath); - return false; - } - - // header check - if (!$content = $this->cut_header(file($lang_file))) { - chdir($tmpPath); - return false; - } - - // check (counting) elements of each lang-entry - foreach ($content as $key => $val) { - $separated = explode($this->separator, trim($val)); - $num = count($separated); - - if ($num !== 3) { - chdir($tmpPath); - return false; - } - } - - chdir($tmpPath); - - // no error occured - return true; + return $this->repository->getInstalledLanguages(); } /** - * Remove *.lang header information from '$content'. - * - * This function seeks for a special keyword where the language information starts. - * If found it returns the plain language information; otherwise returns false. - * - * $content expect an ILIAS lang-file - * - * @return bool|string[] + * get already installed local languages (in db) */ - protected function cut_header(array $content) + public function getInstalledLocalLanguages(): array { - foreach ($content as $key => $val) { - if (trim($val) === "") { - return array_slice($content, $key + 1); - } - } - return false; + return $this->repository->getInstalledLocalLanguages(); } /** - * remove language data from database - * $a_lang_key language key - * $a_mode "all" or "keep_local" + * validate the logical structure of a lang-file + * + * This function checks if a lang-file of a given lang_key exists, + * the file has a header, and each lang-entry consists of exactly + * three elements (module, identifier, value). + * + * $a_lang_key international language key (2 digits) */ - protected function flushLanguage(string $a_lang_key, string $a_mode = "all"): void + protected function checkLanguage(string $a_lang_key): bool { - global $ilDB; - - self::_deleteLangData($a_lang_key, ($a_mode === "keep_local")); - - if ($a_mode === "all") { - $ilDB->manipulate("DELETE FROM lng_modules WHERE lang_key = " . - $ilDB->quote($a_lang_key, "text")); - } + return $this->repository->checkLanguage($a_lang_key); } /** @@ -375,204 +276,28 @@ public static function _deleteLangData(string $a_lang_key, bool $a_keep_local_ch */ public function getLocalChanges(string $a_lang_key, string $a_min_date = "", string $a_max_date = ""): array { - global $ilDB; - - if ($a_min_date === "") { - $a_min_date = "1980-01-01 00:00:00"; - } - if ($a_max_date === "") { - $a_max_date = "2200-01-01 00:00:00"; - } - - $q = sprintf( - "SELECT * FROM lng_data WHERE lang_key = %s " . - "AND local_change >= %s AND local_change <= %s", - $ilDB->quote($a_lang_key, "text"), - $ilDB->quote($a_min_date, "timestamp"), - $ilDB->quote($a_max_date, "timestamp") - ); - $result = $ilDB->query($q); - - $changes = array(); - while ($row = $result->fetchRow(ilDBConstants::FETCHMODE_ASSOC)) { - $changes[$row["module"]][$row["identifier"]] = $row["value"]; - } - return $changes; + return $this->repository->getLocalChanges($a_lang_key, $a_min_date, $a_max_date); } - - //TODO: remove redundant checks here! /** - * insert language data from file in database - * - * $lang_key international language key (2 digits) - * $scope empty (global) or "local" + * Searches for the existence of *.lang.local files. + * Returns array with language keys */ - protected function insertLanguage(string $lang_key, string $scope = ""): void + public function getLocalLanguages(): array { - global $ilDB; - - $lang_array = array(); - - $scopeExtension = ""; - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } - } - - $path = $this->lang_path; - if ($scope === "local") { - $path = $this->cust_lang_path; - } - - $tmpPath = getcwd(); - chdir($path); - - $lang_file = "ilias_" . $lang_key . ".lang" . $scopeExtension; - $change_date = null; - - if (is_file($lang_file)) { - // initialize the array for updating lng_modules below - $lang_array = []; - $lang_array["common"] = []; - - // remove header first - if ($content = $this->cut_header(file($lang_file))) { - // get the local changes from the database - if (empty($scope)) { - $local_changes = $this->getLocalChanges($lang_key); - } elseif ($scope === "local") { - // set the change date to import time for a local file - // get the modification date of the local file - // get the newer local changes for a local file - $change_date = gmdate("Y-m-d H:i:s", time()); - $min_date = gmdate("Y-m-d H:i:s", filemtime($lang_file)); - $local_changes = $this->getLocalChanges($lang_key, $min_date); - } - - $query_check = false; - $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES "; - foreach ($content as $key => $val) { - // split the line of the language file - // [0]: module - // [1]: identifier - // [2]: value - // [3]: comment (optional) - $separated = explode($this->separator, trim($val)); - - //get position of the comment_separator - $pos = strpos($separated[2], $this->comment_separator); - - if ($pos !== false) { - //cut comment of - $separated[2] = substr($separated[2], 0, $pos); - } - - // check if the value has a local change - if (isset($local_changes[$separated[0]])) { - $local_value = $local_changes[$separated[0]][$separated[1]] ?? ""; - } else { - $local_value = ""; - } - - if (empty($scope)) { - if ($local_value !== "" && $local_value !== $separated[2]) { - // keep the locally changed value - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } elseif ($scope === "local") { - if ($local_value !== "") { - // keep a locally changed value that is newer than the local file - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } - - $query .= sprintf( - "(%s,%s,%s,%s,%s,%s),", - $ilDB->quote($separated[0], "text"), - $ilDB->quote($separated[1], "text"), - $ilDB->quote($lang_key, "text"), - $ilDB->quote($separated[2], "text"), - $ilDB->quote($change_date, "timestamp"), - $ilDB->quote($separated[3] ?? null, "text") - ); - $query_check = true; - $lang_array[$separated[0]][$separated[1]] = $separated[2]; - } - $query = rtrim($query, ",") . " ON DUPLICATE KEY UPDATE value=VALUES(value),remarks=VALUES(remarks);"; - if ($query_check) { - $ilDB->manipulate($query); - } - } - - $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES "; - $modules_to_delete = []; - foreach ($lang_array as $module => $lang_arr) { - if ($scope === "local") { - $q = "SELECT * FROM lng_modules WHERE " . - " lang_key = " . $ilDB->quote($lang_key, "text") . - " AND module = " . $ilDB->quote($module, "text"); - $set = $ilDB->query($q); - $row = $ilDB->fetchAssoc($set); - if ($row === null) { - continue; - } - $arr2 = unserialize($row["lang_array"], ["allowed_classes" => false]); - if (is_array($arr2)) { - $lang_arr = array_merge($arr2, $lang_arr); - } - } - $query .= sprintf( - "(%s,%s,%s),", - $ilDB->quote($module, "text"), - $ilDB->quote($lang_key, "text"), - $ilDB->quote(serialize($lang_arr), "clob"), - ); - $modules_to_delete[] = $module; - } - - $inModulesToDelete = $ilDB->in('module', $modules_to_delete, false, 'text'); - $ilDB->manipulate(sprintf( - "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete", - $ilDB->quote($lang_key, "text") - )); - - $query = rtrim($query, ",") . ";"; - $ilDB->manipulate($query); - } - - chdir($tmpPath); + return $this->repository->getLocalLanguages(); } /** - * Searches for the existence of *.lang.local files. - * Returns array with language keys + * Returns local language files whose names contain a requested language key + * but do not match the expected naming scheme. + * + * @param list $language_keys + * @return list */ - public function getLocalLanguages(): array + public function getInvalidLocalLanguageFiles(array $language_keys): array { - $local_langs = array(); - if (is_dir($this->cust_lang_path)) { - $d = dir($this->cust_lang_path); - $tmpPath = getcwd(); - chdir($this->cust_lang_path); - - // get available .lang.local files - while ($entry = $d->read()) { - if (is_file($entry) && (preg_match("~(^ilias_.{2}\.lang.local$)~", $entry))) { - $lang_key = substr($entry, 6, 2); - $local_langs[] = $lang_key; - } - } - - chdir($tmpPath); - } - - return $local_langs; + return $this->repository->getInvalidLocalLanguageFiles($language_keys); } /** @@ -580,32 +305,21 @@ public function getLocalLanguages(): array */ public function getInstallableLanguages(): array { - $d = dir($this->lang_path); - $tmpPath = getcwd(); - chdir($this->lang_path); - - $installableLanguages = []; - // get available lang-files - while ($entry = $d->read()) { - if (is_file($entry) && (preg_match("~(^ilias_.{2}\.lang$)~", $entry))) { - $lang_key = substr($entry, 6, 2); - $installableLanguages[] = $lang_key; - } - } - - chdir($tmpPath); - - return $installableLanguages; + return $this->repository->getInstallableLanguages(); } /** - * set db handler object - * @string object db handler + * Set the database this instance works on, taking precedence over + * $GLOBALS['ilDB']. Used by Setup Objectives to inject the + * Setup-provided database resource; the repository and manager pick it + * up on their next call, because they resolve the database lazily (see + * the constructor). + * * Return true on success */ public function setDbHandler(ilDBInterface $a_db_handler): bool { - $this->db = &$a_db_handler; + $this->db = $a_db_handler; return true; } diff --git a/components/ILIAS/Language/classes/class.ilLanguage.php b/components/ILIAS/Language/classes/class.ilLanguage.php index 5275ee22bc92..65aff45b1d30 100755 --- a/components/ILIAS/Language/classes/class.ilLanguage.php +++ b/components/ILIAS/Language/classes/class.ilLanguage.php @@ -255,7 +255,7 @@ public function loadLanguageModule(string $a_module): void return; } - $q = "SELECT * FROM lng_modules " . + $q = "SELECT lang_array FROM lng_modules " . "WHERE lang_key = " . $ilDB->quote($lang_key, "text") . " AND module = " . $ilDB->quote($a_module, "text"); $r = $ilDB->query($q); @@ -287,6 +287,12 @@ public function getInstalledLanguages(): array /** * Get installed languages + * + * Deliberately separate from ilSetupLanguage::getInstalledLanguages(): + * this one relies on ilObject::_getObjectsByType(), which needs the full + * runtime object repository and is unavailable during Setup, whereas + * ilSetupLanguage's variant uses a raw object_data query that works + * before that machinery exists. Do not merge the two. */ public static function _getInstalledLanguages(): array { @@ -308,7 +314,7 @@ public static function _lookupEntry(string $a_lang_key, string $a_mod, string $a $ilDB = $DIC->database(); $set = $ilDB->query($q = sprintf( - "SELECT * FROM lng_data WHERE module = %s " . + "SELECT value FROM lng_data WHERE module = %s " . "AND lang_key = %s AND identifier = %s", $ilDB->quote($a_mod, "text"), $ilDB->quote($a_lang_key, "text"), diff --git a/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php b/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php index 544677f56c0f..85ee64a9ac15 100644 --- a/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php +++ b/components/ILIAS/Language/classes/class.ilLanguageFolderTable.php @@ -186,24 +186,25 @@ public function getRows( } $record['users'] = ilObjLanguage::countUsers($record["key"]); + $last_local_change = ''; if ($record["desc"] !== "not_installed") { if ($this->access->checkAccess('write', '', $this->folder->getId())) { $record['language'] = $record['language']->withDisabled(false); } $record['last_refresh'] = ilDatePresentation::formatDate(new ilDateTime($record["last_update"], IL_CAL_DATETIME, 'UTC')); - $last_change = ilObjLanguage::_getLastLocalChange($record["key"]); - $record['last_change'] = ilDatePresentation::formatDate(new ilDateTime($last_change, IL_CAL_DATETIME, 'UTC')); + $last_local_change = ilObjLanguage::_getLastLocalChange($record["key"]); + $record['last_change'] = ilDatePresentation::formatDate(new ilDateTime($last_local_change, IL_CAL_DATETIME, 'UTC')); } yield $row_builder->buildDataRow($obj_id, $record) + ->withDisabledAction('install', ($record['desc'] !== 'not_installed')) + ->withDisabledAction('install_local', !str_starts_with($record['desc'], 'installed')) ->withDisabledAction('setSystemLanguage', ($record['desc'] === 'not_installed') || ($record['key'] === $this->lng->getDefaultLanguage())) ->withDisabledAction('setUserLanguage', ($record['desc'] === 'not_installed') || ($record['key'] === $this->lng->getUserLanguage())) ->withDisabledAction('refresh', ($record['desc'] === 'not_installed')) ->withDisabledAction('uninstall', ($record['desc'] === 'not_installed')) - ->withDisabledAction('lang_uninstall_changes', ($record['desc'] === 'not_installed')) - ->withDisabledAction('install', ($record['desc'] !== 'not_installed')) - ->withDisabledAction('install_local', ($record['desc'] !== 'not_installed')); + ->withDisabledAction('lang_uninstall_changes', !str_starts_with($record['desc'], 'installed') || $last_local_change === ''); } } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index eaac24092f6b..f1a216eac4c6 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -18,6 +18,13 @@ declare(strict_types=1); +use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; +use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; +use ILIAS\Language\ComponentTranslation\CustomizingLanguageFileDirectory; +use ILIAS\Language\Setup\InstalledLanguageRepository; +use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\LanguageInstallationManager; + /** * Class ilObjLanguage * @@ -40,6 +47,10 @@ class ilObjLanguage extends ilObject public string $key; public string $status; public string $cust_lang_path; + public string $absolute_path; + private LanguageFileDirectoryManager $language_file_directory_manager; + private InstalledLanguageRepository $repository; + private LanguageInstallationManager $manager; /** * Constructor @@ -47,11 +58,24 @@ class ilObjLanguage extends ilObject * $a_id reference_id or object_id * $a_call_by_reference treat the id as reference_id (true) or object_id (false) */ - public function __construct(int $a_id = 0, bool $a_call_by_reference = false) - { + public function __construct( + int $a_id = 0, + bool $a_call_by_reference = false, + ?LanguageFileDirectoryManager $language_file_directory_manager = null + ) { global $DIC; $lng = $DIC->language(); + // Fallback for when neither an explicit manager is injected nor the + // DIC provides one. The constructor's first argument is the + // local/customizing directory, not a global one - passing + // MainLanguageFileDirectory there (as before) mislabeled the global + // lang/ directory as "local" and left no global directory at all, + // which would make every entry in it look like a local override. + $this->language_file_directory_manager = $language_file_directory_manager + ?? ($DIC[LanguageFileDirectoryManager::class] ?? null) + ?? new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()); + $this->type = "lng"; parent::__construct($a_id, $a_call_by_reference); @@ -64,6 +88,33 @@ public function __construct(int $a_id = 0, bool $a_call_by_reference = false) $this->cust_lang_path = $lng->getCustomLangPath(); $this->separator = $lng->separator; $this->comment_separator = $lng->comment_separator; + // This file lives at components/ILIAS/Language/classes/ - four + // levels below the ILIAS root, not five. The extra "../" here used + // to point one directory too high (e.g. /var/www instead of + // /var/www/html), so the "Main" language directory (lang/) was never + // found - check()/refresh()/removeLocalChanges() would then always + // fail with "file not valid", for every language, even though the + // file itself was fine. Compare Language.php's own (correct) + // 3-level "../../../ " from components/ILIAS/Language/. + $this->absolute_path = (string) realpath(__DIR__ . "/../../../../"); + + // Single source of truth for file-based language check/insert + // operations - see check()/insert() below. This avoids duplicating + // the file-parsing/DB-writing logic that used to live separately in + // this class and in ilObjLanguageDBAccess. Repository (read) and + // Manager (write) used to be bundled into ilSetupLanguage - see its + // class docblock and docs/development/repository-pattern.md. + $this->repository = new InstalledLanguageDatabaseRepository( + $DIC->database(), + $this->language_file_directory_manager, + $this->absolute_path + ); + $this->manager = new LanguageInstallationManager( + $DIC->database(), + $this->language_file_directory_manager, + $this->absolute_path, + $this->repository + ); } @@ -89,6 +140,12 @@ public static function getInstalledLanguages(): array /** * Return the language keys of the installed languages * + * KNOWN ISSUE (not fixed here, flagged for a follow-up): this checks for + * the exact status "installed" and therefore misses languages with the + * status "installed_local", unlike ilLanguage::_getInstalledLanguages() + * which matches on str_starts_with($desc, "installed") and thus includes + * both. This affects ilPluginLanguage's language selection. + * * @return array */ public static function getLangKeysOfInstalledLanguages(): array @@ -184,12 +241,8 @@ public function isLocal(): bool */ public function install(string $scope = ""): string { - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } + if ($scope === "global") { + $scope = ""; } if (!$this->isInstalled() || (!$this->isLocal() && !empty($scope))) { @@ -240,6 +293,10 @@ public function uninstall(): string /** * refresh current language + * + * A single insert() call now covers both global and local/customizing + * content in one pass (see insert()'s doc comment), so the separate + * "refresh local on top" pass that used to follow is no longer needed. */ public function refresh(): bool { @@ -250,13 +307,6 @@ public function refresh(): bool $this->setDescription($this->getStatus()); $this->update(); - if ($this->isLocal() && $this->check("local")) { - $this->insert("local"); - $this->setTitle($this->getKey()); - $this->setDescription($this->getStatus()); - $this->update(); - } - return true; } @@ -264,8 +314,17 @@ public function refresh(): bool } /** - * Refresh all installed languages - */ + * Refresh all installed languages. + * + * @deprecated its only caller in this codebase, + * ilObjLanguageFolderGUI::refreshObject(), was removed as unreachable + * dead code (no link or ilCtrl command ever dispatched to it - verified + * repository-wide) once ilObjLanguageFolderGUI::refreshSelectedObject() + * was migrated to \ILIAS\Language\Activities\UpdateLanguage. Kept here, + * rather than removed outright, only because this is a public static + * method of a public API class that external/plugin code could still be + * calling directly. + */ public static function refreshAll(): void { $languages = ilObject::_getObjectsByType("lng"); @@ -358,7 +417,7 @@ public function getLocalChanges(string $a_min_date = "", string $a_max_date = "" } $q = sprintf( - "SELECT * FROM lng_data WHERE lang_key = %s " . + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s " . "AND local_change >= %s AND local_change <= %s", $ilDB->quote($this->key, "text"), $ilDB->quote($a_min_date, "timestamp"), @@ -412,7 +471,7 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) $changes = array(); $result = $ilDB->queryF( - "SELECT * FROM lng_data WHERE lang_key = %s AND module = %s AND local_change IS NOT NULL", + "SELECT identifier, value FROM lng_data WHERE lang_key = %s AND module = %s AND local_change IS NOT NULL", array("text", "text"), array($a_key, $a_module) ); @@ -427,46 +486,49 @@ public static function _getLocalChangesByModule(string $a_key, string $a_module) /** * insert language data from file into database * - * $scope empty (global) or "local" + * @deprecated $scope is accepted for backwards compatibility but no + * longer selects a separate write path: LanguageInstallationManager::insertLanguageForInstallation() + * always processes every directory the LanguageFileDirectoryManager + * knows about (global/component directories *and* the customizing/local + * one) in a single, idempotent pass, correctly preserving local + * overrides for global entries. This replaces the previous separate + * ilObjLanguageDBAccess-based write path (removed). No caller in this + * repository passes a non-empty $scope any more (verified repo-wide) - + * the parameter is kept only in case external/plugin code still calls + * this public method with one; remove it once that can be ruled out. */ public function insert(string $scope = ""): void { - global $DIC; - $ilDB = $DIC->database(); - $scopeExtension = ""; - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } - } + $this->manager->insertLanguageForInstallation($this->key); + } - $path = $this->lang_path; - if ($scope === "local") { - $path = $this->cust_lang_path; + /** + * Remove all local changes of this language - both entries edited + * directly via the "adjust language variables" table and any override + * coming from a customizing/local language file - and reinstall the + * language purely from the global/component language files. + * + * This must go through insertLanguageForRemovingLocalChanges() rather + * than insert()/insertLanguageForInstallation(): the latter always + * merges the customizing directory back in, which would immediately + * reinstate the very data this method is supposed to remove (see + * LanguageInstallationManager::insertLanguageForRemovingLocalChanges()). + * + * Return true if the language was installed and could be reinstalled + */ + public function removeLocalChanges(): bool + { + if (!$this->isInstalled() || !$this->check()) { + return false; } - $lang_file = $path . "/ilias_" . $this->key . ".lang" . $scopeExtension; + $this->flush("all"); + $this->manager->insertLanguageForRemovingLocalChanges($this->key); + $this->setTitle($this->getKey()); + $this->setDescription("installed"); + $this->update(); - if (is_file($lang_file)) { - // remove header first - if ($content = self::cut_header(file($lang_file))) { - $local_changes = null; - if (empty($scope)) { - // get all local changes for a global file - $local_changes = $this->getLocalChanges(); - } elseif ($scope === "local") { - // get the modification date of the local file - // get the newer local changes for a local file - $min_date = gmdate("Y-m-d H:i:s", filemtime($lang_file)); - $local_changes = $this->getLocalChanges($min_date); - } - $dbAccess = new ilObjLanguageDBAccess($ilDB, $this->key, $content, $local_changes, $scope); - $lang_array = $dbAccess->insertLangEntries($lang_file); - $dbAccess->replaceLangModules($lang_array); - } - } + return true; } /** @@ -682,94 +744,23 @@ public function optimizeData(): bool * header, and each lang-entry consists of exactly three elements * (module, identifier, value). * - * $scope empty (global) or "local" - * Return system message + * $scope empty/"global" (all managed directories) or "local" + * (customizing directory only) + * + * Delegates to InstalledLanguageRepository, which already implements + * this validation, keyed off the LanguageFileDirectoryManager. This + * used to be a separate, largely duplicated implementation that also + * caused hard UI redirects from this model class - callers (e.g. + * ilObjLanguageFolderGUI) are now responsible for turning a `false` + * return into user-facing feedback. */ public function check(string $scope = ""): bool { - global $DIC; - $scopeExtension = ""; - if (!empty($scope)) { - if ($scope === "global") { - $scope = ""; - } else { - $scopeExtension = "." . $scope; - } - } - - $path = $this->lang_path; if ($scope === "local") { - $path = $this->cust_lang_path; - } - - $tmpPath = getcwd(); - - // dir check - if (!is_dir($path)) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Directory not found: " . $path, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); + return $this->repository->checkLocalLanguageFile($this->key); } - chdir($path); - - // compute lang-file name format - $lang_file = "ilias_" . $this->key . ".lang" . $scopeExtension; - - // file check - if (!is_file($lang_file)) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "File not found: " . $lang_file, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - - // header check - $content = self::cut_header(file($lang_file)); - if ($content === false) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Wrong Header in " . $lang_file, - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - - // check (counting) elements of each lang-entry - $line = 0; - $n = 0; - foreach ($content as $key => $val) { - $separated = explode($this->separator, trim($val)); - $num = count($separated); - ++$n; - if ($num !== 3) { - $line = $n + 36; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Wrong parameter count in " . $lang_file . " in line $line (Value: $val)! Please check your language file!", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - if (!ilStr::isUtf8($separated[2])) { - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Non UTF8 character found in " . $lang_file . " in line $line (Value: $val)! Please check your language file!", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - } - - chdir($tmpPath); - - // no error occured - return true; + return $this->repository->checkLanguage($this->key); } /** diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php b/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php index 4d6fd6e62c15..cb53a7dd5ed7 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageAccess.php @@ -186,8 +186,10 @@ public static function _saveUsages(): void $lng = $DIC->language(); if (self::_checkTranslate() and !self::_isPageTranslation()) { - ilSession::set("lang_ext_maintenance", array("used_modules" => array_keys($lng->getUsedModules()))); - ilSession::set("lang_ext_maintenance", array("used_topics" => array_keys($lng->getUsedTopics()))); + ilSession::set("lang_ext_maintenance", array( + "used_modules" => array_keys($lng->getUsedModules()), + "used_topics" => array_keys($lng->getUsedTopics()) + )); } } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php b/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php deleted file mode 100755 index f3a53f90daab..000000000000 --- a/components/ILIAS/Language/classes/class.ilObjLanguageDBAccess.php +++ /dev/null @@ -1,185 +0,0 @@ -ilDB = $ilDB; - $this->key = $key; - $this->content = $content; - $this->local_changes = $local_changes; - $this->scope = $scope; - if ($scope === "local") { - $this->change_date = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) - ->format('Y-m-d H:i:s'); - } - $this->separator = $separator; - $this->comment_separator = $comment_separator; - } - - public function insertLangEntries(string $lang_file): array - { - // initialize the array for updating lng_modules below - $lang_array = array(); - $lang_array["common"] = array(); - - $double_checker = []; - $query_check = false; - $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES "; - foreach ($this->content as $val) { - // split the line of the language file - // [0]: module - // [1]: identifier - // [2]: value - // [3]: comment (optional) - $separated = explode($this->separator, trim($val)); - $pos = strpos($separated[2], $this->comment_separator); - if ($pos !== false) { - $separated[3] = substr($separated[2], $pos + strlen($this->comment_separator)); - $separated[2] = substr($separated[2], 0, $pos); - } - - // check if the value has a local change - $local_value = $this->local_changes[$separated[0]][$separated[1]] ?? ""; - - if (empty($this->scope)) { - // import of a global language file - if ($local_value !== "" && $local_value !== $separated[2]) { - // keep an existing and different local value - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } elseif ($this->scope === "local") { - // import of a local language file - if ($local_value !== "") { - // keep a locally changed value that is newer than the file - $lang_array[$separated[0]][$separated[1]] = $local_value; - continue; - } - } - if ($double_checker[$separated[0]][$separated[1]][$this->key] ?? false) { - global $DIC; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Duplicate Language Entry in $lang_file:\n$val", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - $double_checker[$separated[0]][$separated[1]][$this->key] = true; - - $query .= sprintf( - "(%s,%s,%s,%s,%s,%s),", - $this->ilDB->quote($separated[0], "text"), - $this->ilDB->quote($separated[1], "text"), - $this->ilDB->quote($this->key, "text"), - $this->ilDB->quote($separated[2], "text"), - $this->ilDB->quote($this->change_date, "timestamp"), - $this->ilDB->quote($separated[3] ?? null, "text") - ); - $query_check = true; - $lang_array[$separated[0]][$separated[1]] = $separated[2]; - } - $query = rtrim($query, ",") . " ON DUPLICATE KEY UPDATE value=VALUES(value),local_change=VALUES(local_change),remarks=VALUES(remarks);"; - if ($query_check) { - $this->ilDB->manipulate($query); - } - - return $lang_array; - } - - public function replaceLangModules(array $lang_array): void - { - // avoid flushing the whole cache (see mantis #28818) - ilCachedLanguage::getInstance($this->key)->deleteInCache(); - - $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES "; - $modules_to_delete = []; - foreach ($lang_array as $module => $lang_arr) { - if ($this->scope === "local") { - $q = "SELECT * FROM lng_modules WHERE " . - " lang_key = " . $this->ilDB->quote($this->key, "text") . - " AND module = " . $this->ilDB->quote($module, "text"); - $set = $this->ilDB->query($q); - $row = $this->ilDB->fetchAssoc($set); - if ($row === null) { - continue; - } - $arr2 = unserialize($row["lang_array"], ["allowed_classes" => false]); - if (is_array($arr2)) { - $lang_arr = array_merge($arr2, $lang_arr); - } - } - $query .= sprintf( - "(%s,%s,%s),", - $this->ilDB->quote($module, "text"), - $this->ilDB->quote($this->key, "text"), - $this->ilDB->quote(serialize($lang_arr), "clob"), - ); - $modules_to_delete[] = $module; - } - - $inModulesToDelete = $this->ilDB->in('module', $modules_to_delete, false, 'text'); - $this->ilDB->manipulate(sprintf( - "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete", - $this->ilDB->quote($this->key, "text") - )); - - $query = rtrim($query, ",") . ";"; - $this->ilDB->manipulate($query); - - // check if the module is correctly saved - // see mantis #20046 and #19140 - $this->checkModules(); - } - - protected function checkModules(): void - { - $result = $this->ilDB->queryF( - "SELECT module, lang_array FROM lng_modules WHERE lang_key = %s", - array("text"), - array($this->key) - ); - - foreach ($this->ilDB->fetchAll($result) as $module) { - $unserialied = unserialize($module["lang_array"], ["allowed_classes" => false]); - if (!is_array($unserialied)) { - global $DIC; - $DIC->ui()->mainTemplate()->setOnScreenMessage( - 'failure', - "Data for module '" . $module["module"] . "' of language '" . $this->key . "' is not correctly saved. " . - "Please check the collation of your database tables lng_data and lng_modules. It must be utf8_unicode_ci.", - true - ); - $DIC->ctrl()->redirectByClass(ilobjlanguagefoldergui::class, 'view'); - } - } - } -} diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php index 162b832986a7..a9568c1f0ec8 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExt.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExt.php @@ -362,7 +362,7 @@ public static function _getValues( $ilDB = $DIC->database(); $lng = $DIC->language(); - $q = "SELECT * FROM lng_data WHERE" . + $q = "SELECT module, identifier, value FROM lng_data WHERE" . " lang_key = " . $ilDB->quote($a_lang_key, "text") . " "; if (is_array($a_modules) && count($a_modules) > 0) { @@ -449,7 +449,7 @@ public static function _saveValues(string $a_lang_key, array $a_values = array() // save the serialized module entries in lng_modules foreach ($save_array as $module => $entries) { $set = $ilDB->query(sprintf( - "SELECT * FROM lng_modules " . + "SELECT lang_array FROM lng_modules " . "WHERE lang_key = %s AND module = %s", $ilDB->quote($a_lang_key, "text"), $ilDB->quote($module, "text") @@ -522,7 +522,7 @@ public static function _deleteValues(string $a_lang_key, array $a_values = array // save the serialized module entries in lng_modules foreach ($delete_array as $module => $entries) { $set = $ilDB->query(sprintf( - "SELECT * FROM lng_modules " . + "SELECT lang_array FROM lng_modules " . "WHERE lang_key = %s AND module = %s", $ilDB->quote($a_lang_key, "text"), $ilDB->quote($module, "text") diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php index dc5e5bac27dd..3b7b95b155e0 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageExtGUI.php @@ -22,6 +22,10 @@ use ILIAS\FileUpload\Location; use ILIAS\HTTP\Services as HTTPServices; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Language\Activities\AddLanguageEntry; +use ILIAS\Language\Activities\SetLanguageTranslationEnabled; +use ILIAS\Language\Activities\SafeToDisplayActivityError; +use ILIAS\Language\RendersActivityErrors; /** * Class ilObjLanguageExtGUI @@ -39,8 +43,12 @@ */ class ilObjLanguageExtGUI extends ilObjectGUI { - private const ILIAS_LANGUAGE_MODULE = "components/ILIAS/Language"; + use RendersActivityErrors; + + private const string ILIAS_LANGUAGE_MODULE = "components/ILIAS/Language"; private string $langmode; + private readonly AddLanguageEntry $add_language_entry; + private readonly SetLanguageTranslationEnabled $set_language_translation_enabled; /** * Constructor @@ -61,6 +69,15 @@ public function __construct($a_data, int $a_id = 0, bool $a_call_by_reference = $lng = $DIC->language(); $this->http = $DIC['http']; $this->refinery = $DIC['refinery']; + // Resolved once here, per the constructor-injection idiom already + // used by sibling GUI classes in this component (see + // ilObjLanguageFolderGUI::__construct()) - action methods must not + // reach into `global $DIC` themselves. + $this->add_language_entry = $DIC[AddLanguageEntry::class]; + $this->set_language_translation_enabled = $DIC[SetLanguageTranslationEnabled::class]; + // Used exclusively by activityErrorMessage() (see RendersActivityErrors) - + // resolved once here, per the same idiom as the Activities above. + $this->activity_error_logger = $DIC->logger()->lang(); // language maintenance strings are defined in administration $lng->loadLanguageModule("administration"); @@ -581,8 +598,10 @@ public function downloadObject(): void $tmp["export"]["scope"] = ilUtil::stripSlashes($post_scope); ilSession::set("lang_ext_maintenance", $tmp); + $pos = strpos(ILIAS_VERSION, " "); + $pos = $pos === false ? null : $pos; $filename = "ilias_" . $this->object->key . '_' - . str_replace(".", "_", substr(ILIAS_VERSION, 0, strpos(ILIAS_VERSION, " "))) + . str_replace(".", "_", substr(ILIAS_VERSION, 0, $pos)) . "-" . gmdate("Y-m-d") . ".lang." . $this->getSession()["export"]["scope"]; @@ -761,24 +780,57 @@ public function settingsObject(): void } /** - * Set the language settings + * Set the language settings - see SetLanguageTranslationEnabled. + * + * An unchecked HTML checkbox is not submitted at all, so a missing/empty + * "translation" POST value means "disabled", exactly as the extracted + * code's `?? ""` default did. SetLanguageTranslationEnabled::perform() + * requires `enabled` as a strict bool and itself derives the setting's + * current state via `(bool) $this->settings->get($translate_key, '0')` - + * the same true/false convention the raw stored string already encoded - + * so this strict bool is equivalent to the raw value the form used to + * carry directly. */ public function saveSettingsObject(): void { - global $DIC; - $ilSetting = $DIC->settings(); - - $translate_key = "lang_translate_" . $this->object->key; - - $post_translation = $this->http->request()->getParsedBody()['translation'] ?? ""; - // save and get the page translation setting - $translate = $ilSetting->get($translate_key, '0'); - if (!is_null($post_translation) && $post_translation != $translate) { - $ilSetting->set($translate_key, $post_translation); + // $this->user/$this->ctrl (set up by the parent ilObjectGUI + // constructor) are used here rather than reaching into global $DIC - + // see the comment on the constructor-injected Activities above for + // why action methods must not reach into it themselves. + $ilUser = $this->user; + + $post_translation = $this->http->request()->getParsedBody()['translation'] ?? null; + $enabled = $post_translation !== null && $post_translation !== ''; + + $result = $this->set_language_translation_enabled->maybePerformAs( + $this->ui_factory->input(), + $ilUser->getId(), + [ + 'language_key' => $this->object->key, + 'enabled' => $enabled, + ] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + $this->tpl->setOnScreenMessage('failure', $error_message); + } elseif ($result->value()['changed']) { + // Only shown if the value actually changed - exactly reproducing + // the extracted code's original behaviour: 'changed' comes from + // SetLanguageTranslationEnabled::perform()'s own strict bool + // comparison against the previously stored setting. $this->tpl->setOnScreenMessage('success', $this->lng->txt("settings_saved")); } + $form = $this->initNewSettingsForm(); + // Unlike installObject()/uninstallObject()/refreshSelectedObject(), + // which redirect after a persisted message, this preserves the + // extracted code's original behaviour: render the settings form + // directly with a non-persisted message, rather than an HTTP + // redirect. $this->tpl->setContent($form->getHTML()); } @@ -1015,45 +1067,68 @@ protected function initAddNewEntryForm(?string $a_id = null): ilPropertyFormGUI public function saveNewEntryObject(): void { - global $DIC; - $ilDB = $DIC->database(); - $ilCtrl = $DIC->ctrl(); - $ilUser = $DIC->user(); + // $this->ctrl/$this->user (set up by the parent ilObjectGUI + // constructor) are used here rather than reaching into global $DIC - + // see the comment on the constructor-injected Activities above for + // why action methods must not reach into it themselves. + $ilCtrl = $this->ctrl; + $ilUser = $this->user; $form = $this->initAddNewEntryForm(); if ($form->checkInput()) { $mod = $form->getInput("mod"); $id = $form->getInput("id"); - $lang = array(); + // One value per installed language - an installed language left + // blank by the user is skipped entirely by AddLanguageEntry, + // exactly as this loop used to skip it via `if ($trans) {...}`. + $translations = []; foreach ($this->lng->getInstalledLanguages() as $lang_key) { - $trans = trim($form->getInput("trans_" . $lang_key)); - if ($trans) { - // add single entry - ilObjLanguage::replaceLangEntry( - $mod, - $id, - $lang_key, - $trans, - gmdate("Y-m-d H:i:s"), - $ilUser->getLogin() - ); + $translations[$lang_key] = trim((string) $form->getInput("trans_" . $lang_key)); + } - // add to serialized module - $set = $ilDB->query("SELECT lang_array FROM lng_modules" . - " WHERE lang_key = " . $ilDB->quote($lang_key, "text") . - " AND module = " . $ilDB->quote($mod, "text")); - $row = $ilDB->fetchAssoc($set); - $entries = unserialize($row["lang_array"], ["allowed_classes" => false]); - if (is_array($entries)) { - $entries[$id] = $trans; - ilObjLanguage::replaceLangModule($lang_key, $mod, $entries); - } + $result = $this->add_language_entry->maybePerformAs( + $this->ui_factory->input(), + $ilUser->getId(), + [ + 'module' => $mod, + 'identifier' => $id, + 'translations' => $translations, + ] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + if ($error instanceof SafeToDisplayActivityError) { + // A genuine input rejection (e.g. a missing/blank "de"/ + // "en" translation - AddLanguageEntry::perform() rejects + // the whole request if either is blank, mirroring the + // legacy ilPropertyFormGUI's setRequired(true) on these + // two fields; other languages left blank are merely + // skipped) - re-render the same form with the values + // the user already entered still filled in, exactly like + // the $form->checkInput() === false branch below does, + // instead of redirecting to "view" and discarding them. + $this->tpl->setOnScreenMessage('failure', $error_message); + $form->setValuesByPost(); + $this->addNewEntryObject($form); + return; } + + // An unexpected internal failure (already logged by + // activityErrorMessage()) - redirecting away, losing the + // entered values, matches every other Activity error path in + // this component. + $this->tpl->setOnScreenMessage('failure', $error_message, true); + $ilCtrl->redirect($this, "view"); + return; } $this->tpl->setOnScreenMessage('success', $this->lng->txt("settings_saved"), true); $ilCtrl->redirect($this, "view"); + return; } $form->setValuesByPost(); diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index 03a635cc0d19..f6a7d8b26365 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -20,6 +20,12 @@ use ILIAS\UI\URLBuilder; use ILIAS\UI\URLBuilderToken; +use ILIAS\Language\Activities\InstallLanguage; +use ILIAS\Language\Activities\UpdateLanguage; +use ILIAS\Language\Activities\UninstallLanguage; +use ILIAS\Language\Activities\RemoveLocalLanguageChanges; +use ILIAS\Language\Activities\SetLanguageDetectionEnabled; +use ILIAS\Language\RendersActivityErrors; /** * Class ilObjLanguageFolderGUI @@ -33,20 +39,44 @@ */ class ilObjLanguageFolderGUI extends ilObjectGUI { + use RendersActivityErrors; + protected ilLanguageFolderTable $languageFolderTable; protected ILIAS\Data\Factory $df; protected URLBuilder $url_builder; protected URLBuilderToken $action_token; protected URLBuilderToken $id_token; + private readonly InstallLanguage $install_language; + private readonly UpdateLanguage $update_language; + private readonly UninstallLanguage $uninstall_language; + private readonly RemoveLocalLanguageChanges $remove_local_language_changes; + private readonly SetLanguageDetectionEnabled $set_language_detection_enabled; + private readonly int $current_user_id; /** * Constructor */ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference) { + global $DIC; + $this->type = "lngf"; parent::__construct($a_data, $a_id, $a_call_by_reference, false); $this->lng->loadLanguageModule("lng"); + + // Resolved once here, per the constructor-injection idiom already + // used by sibling GUI classes in this component (see + // ilObjLanguageExtGUI::__construct()) - action methods must not + // reach into `global $DIC` themselves. + $this->install_language = $DIC[InstallLanguage::class]; + $this->update_language = $DIC[UpdateLanguage::class]; + $this->uninstall_language = $DIC[UninstallLanguage::class]; + $this->remove_local_language_changes = $DIC[RemoveLocalLanguageChanges::class]; + $this->set_language_detection_enabled = $DIC[SetLanguageDetectionEnabled::class]; + $this->current_user_id = $DIC->user()->getId(); + // Used exclusively by activityErrorMessage() (see RendersActivityErrors) - + // resolved once here, per the same idiom as the Activities above. + $this->activity_error_logger = $DIC->logger()->lang(); $this->df = new ILIAS\Data\Factory(); $here_uri = $this->df->uri($this->request->getUri()->__toString()); @@ -147,6 +177,16 @@ protected function buildConfirmModal(array $ids, string $title, string $action, $items = []; if (!empty($ids)) { + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + // The request has already been aborted above (a failure + // message plus a redirect to "view") - this method's return + // type still requires a Modal value, but it is never + // actually rendered to the user: the redirect() response + // takes precedence in production. See + // abortIfAnyIdIsNotALanguageObject() for why. + return $f->modal()->interruptive($title, '', '')->withActionButtonLabel($this->lng->txt('ok')); + } + $message = $this->lng->txt($text); $some_changed = false; @@ -189,212 +229,439 @@ protected function buildConfirmModal(array $ids, string $title, string $action, } /** - * install languages + * Turn a list of language keys into a localized, comma-separated list + * (e.g. for "German, French installed." style messages). + * + * @param list $lang_keys */ - public function installObject(array $ids): void + private function languageKeysToLocalizedList(array $lang_keys): string { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); + return implode(', ', array_map( + fn(string $key): string => $this->lng->txt('meta_l_' . $key), + $lang_keys + )); + } + /** + * Restores, for a batch of ids taken directly from request input (see + * getIdsFromQueryToken()), the implicit type guarantee + * `new ilObjLanguage((int) $obj_id)` used to give before every caller + * below was switched away from it towards `ilObject::_lookupTitle()`: the + * former threw ilObjectTypeMismatchException for a non-"lng" id, while + * the latter returns ANY object's title regardless of type (or "" for a + * non-existing id) without any type check at all. $ids is completely + * unvalidated request input, so every id must be confirmed to actually be + * a "lng" object BEFORE its title is trusted as a language key anywhere + * in this class. + * + * Aborts the WHOLE request - a generic, user-visible failure message + * followed by a redirect back to "view" - the moment a SINGLE id in + * $ids is not a "lng" object, rather than silently skipping just that + * one: a caller submitting an id that does not belong on this screen at + * all must never have any part of their request honoured. The message + * deliberately does not name the actual mismatch (e.g. which id, or what + * type it actually is), to avoid confirming to a caller probing this + * parameter which ids exist and of what type. + * + * `ilObject::_lookupType()` is backed by `ilObjDataCache` (see its own + * implementation), exactly like `ilObject::_lookupTitle()` already is - + * so checking every id here costs no additional, uncached database + * round trip compared to before. + * + * @param list $ids + * @return bool true if the request was aborted (the caller must return + * immediately without acting on $ids at all); false if every id + * in $ids is confirmed to be a "lng" object. + */ + private function abortIfAnyIdIsNotALanguageObject(array $ids): bool + { foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install(); - - if ($key !== "") { - $lang_installed[] = $key; - } - - unset($langObj); - } - - if (isset($lang_installed)) { - if (count($lang_installed) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_installed[0]) . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($lang_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data = implode(", ", $langnames) . " " . strtolower($this->lng->txt("installed")) . "."; + if (ilObject::_lookupType((int) $obj_id) !== 'lng') { + $this->tpl->setOnScreenMessage( + 'failure', + $this->lng->txt('obj_not_found') . '
' . $this->lng->txt('action_aborted'), + true + ); + $this->ctrl->redirect($this, 'view'); + return true; } - } else { - $this->data = $this->lng->txt("languages_already_installed"); } - - $this->out(); + return false; } - /** - * Install local language modifications. + * Install languages, or (re-)apply just their customizing/local file - + * see InstallLanguage::MODE_INSTALL / MODE_INSTALL_LOCAL. $mode is + * passed straight from the "install"/"install_local" command that was + * dispatched (see executeCommand()) - both are valid InstallLanguage + * modes as-is. */ - public function installLocalObject(array $ids): void + public function installObject(array $ids, string $mode): void { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); + $this->checkPermission('write'); + + // An empty selection must never reach InstallLanguage itself: its + // own field-level validation would reject it with a technical, + // untranslated message (e.g. "language_keys: not_min_length") - + // exactly the same "nothing was selected" case buildConfirmModal() + // already handles for the confirmation step, so the same + // established message is shown here too. + if ($ids === []) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_checkbox'), true); + $this->ctrl->redirect($this, 'view'); + return; + } + + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $language_keys = []; foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install(); + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } - if ($key !== "") { - $lang_installed[] = $key; - } + $result = $this->install_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + [ + 'language_keys' => $language_keys, + 'mode' => $mode, + ] + ); - unset($langObj); + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message (see uninstallObject()) - a fixed + // "language_not_installed" prefix does not apply to every + // possible error here (e.g. a permission failure, or an invalid + // language file), so it must not be prepended unconditionally. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); - $langObj = new ilObjLanguage((int) $obj_id); - $key = $langObj->install("local"); + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // Both of these are "success" messages, but setOnScreenMessage() + // only keeps one message per type (a second call for the same type + // would silently overwrite the first) - so if both buckets are + // non-empty in the same request (e.g. installing several languages + // at once, some plain, some with a custom file), they are combined + // into a single message instead of two separate calls. + $success_messages = []; + + if (($lang_installed = $value['installed_language_keys']) !== []) { + $success_messages[] = $this->languageKeysToLocalizedList($lang_installed) + . " " . strtolower($this->lng->txt("installed")) . "."; + } - if ($key !== "") { - $local_installed[] = $key; - } + // A language gets a customizing/local file (re-)applied either as + // part of a fresh installation (mode "install") or as the result of + // mode "install_local" - either way, something did change for it, so + // this must be reported explicitly rather than disappearing into + // "already installed", which would wrongly suggest nothing happened. + if (($lang_installed_with_local_file = $value['installed_with_local_language_keys']) !== []) { + $success_messages[] = $this->languageKeysToLocalizedList($lang_installed_with_local_file) + . ": " . $this->lng->txt("installed_local") . "."; + } - unset($langObj); + if ($success_messages !== []) { + $this->tpl->setOnScreenMessage( + 'success', + implode('
', $success_messages), + true + ); } - if (isset($lang_installed)) { - if (count($lang_installed) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_installed[0]) . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($lang_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data = implode(", ", $langnames) . " " . strtolower($this->lng->txt("installed")) . "."; - } + // Both of these are "info" messages about requests that changed + // nothing at all (mode "install" on an already installed language, + // or mode "install_local" on a language that is not installed) - + // combined into a single message for the same reason as the success + // messages above. + $info_messages = []; + + if (($lang_already_installed = $value['already_installed_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("languages_already_installed") . ': ' + . $this->languageKeysToLocalizedList($lang_already_installed); } - if (isset($local_installed)) { - if (count($local_installed) === 1) { - $this->data .= " " . $this->lng->txt("meta_l_" . $local_installed[0]) . " " . $this->lng->txt("local_language_file") . " " . strtolower($this->lng->txt("installed")) . "."; - } else { - $langnames = []; - foreach ($local_installed as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } - $this->data .= " " . implode(", ", $langnames) . " " . $this->lng->txt("local_language_files") . " " . strtolower($this->lng->txt("installed")) . "."; - } - } else { - $this->data .= " " . $this->lng->txt("local_languages_already_installed"); + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $info_messages[] = $this->languageKeysToLocalizedList($lang_not_installed) + . " " . $this->lng->txt("language_not_installed"); } - $this->out(); + if ($info_messages !== []) { + $this->tpl->setOnScreenMessage( + 'info', + implode('
', $info_messages), + true + ); + } + + if (($invalid_local_language_files = $value['invalid_local_language_files']) !== []) { + $message = $this->lng->txt('local_language_files') . ': ' + . implode(', ', $invalid_local_language_files) . '. ' + . $this->lng->txt('file_not_valid'); + $this->tpl->setOnScreenMessage( + 'failure', + $message, + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** - * uninstall language + * Uninstall languages - see UninstallLanguage. A language among $ids + * that is not installed, the system language, or the language currently + * in use is left completely untouched. */ public function uninstallObject(array $ids): void { $this->checkPermission('write'); $this->lng->loadLanguageModule("meta"); - $sys_lang = false; - $usr_lang = false; + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } - // uninstall all selected languages + $language_keys = []; foreach ($ids as $obj_id) { - $langObj = new ilObjLanguage((int) $obj_id); - if (!($sys_lang = $langObj->isSystemLanguage()) && !($usr_lang = $langObj->isUserLanguage())) { - $key = $langObj->uninstall(); - if ($key !== "") { - $lang_uninstalled[] = $key; - } - } - unset($langObj); + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); } - // generate output message - if (isset($lang_uninstalled)) { - if (count($lang_uninstalled) === 1) { - $this->data = $this->lng->txt("meta_l_" . $lang_uninstalled[0]) . " " . $this->lng->txt("uninstalled"); - } else { - $langnames = []; - foreach ($lang_uninstalled as $lang_key) { - $langnames[] = $this->lng->txt("meta_l_" . $lang_key); - } + $result = $this->uninstall_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); - $this->data = implode(", ", $langnames) . " " . $this->lng->txt("uninstalled"); - } - } elseif ($sys_lang) { - $this->data = $this->lng->txt("cannot_uninstall_systemlanguage"); - } elseif ($usr_lang) { - $this->data = $this->lng->txt("cannot_uninstall_language_in_use"); - } else { - $this->data = $this->lng->txt("languages_already_uninstalled"); + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; } + $value = $result->value(); - $this->out(); + if (($lang_uninstalled = $value['uninstalled_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->languageKeysToLocalizedList($lang_uninstalled) . " " . $this->lng->txt("uninstalled"), + true + ); + } + + // These three outcomes are all "nothing changed for this language, + // and here is why" - combined into a single info message for the + // same reason as installObject()/refreshSelectedObject() combine + // theirs: setOnScreenMessage() only keeps one message per type. + $info_messages = []; + + if (($lang_system = $value['system_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("cannot_uninstall_systemlanguage") . ': ' + . $this->languageKeysToLocalizedList($lang_system); + } + + if (($lang_in_use = $value['user_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("cannot_uninstall_language_in_use") . ': ' + . $this->languageKeysToLocalizedList($lang_in_use); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $info_messages[] = $this->lng->txt("languages_already_uninstalled") . ': ' + . $this->languageKeysToLocalizedList($lang_not_installed); + } + + if ($info_messages !== []) { + $this->tpl->setOnScreenMessage('info', implode('
', $info_messages), true); + } + + $this->ctrl->redirect($this, 'view'); } /** - * Uninstall local changes in the database + * Remove local changes of one or more already installed languages - see + * RemoveLocalLanguageChanges. A language among $ids that is not + * installed (or not a known language key at all) is left completely + * untouched. */ public function uninstallChangesObject(array $ids): void { $this->checkPermission("write"); - - $this->data = $this->lng->txt("selected_languages_updated"); $this->lng->loadLanguageModule("meta"); - $refreshed = []; - foreach ($ids as $id) { - $langObj = new ilObjLanguage((int) $id, false); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } - if ($langObj->isInstalled()) { - if ($langObj->check()) { - $langObj->flush("all"); - $langObj->insert(); - $langObj->setTitle($langObj->getKey()); - $langObj->setDescription("installed"); - $langObj->update(); - $refreshed[] = $langObj->getKey(); - } - $this->data .= "
" . $this->lng->txt("meta_l_" . $langObj->getKey()); - } + $language_keys = []; + foreach ($ids as $obj_id) { + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } - unset($langObj); + $result = $this->remove_local_language_changes->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // Plugin language files are refreshed only for the languages whose + // local changes were actually removed - a requested-but-not-installed + // or invalid language (see below) has nothing to refresh. + ilObjLanguage::refreshPlugins($value['removed_local_changes_language_keys']); + + if (($lang_changed = $value['removed_local_changes_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->lng->txt("selected_languages_updated") . "
" + . $this->languageKeysToLocalizedList($lang_changed), + true + ); } - ilObjLanguage::refreshPlugins($refreshed); - $this->out(); - } - /** - * update all installed languages - */ - public function refreshObject(): void - { - $this->checkPermission("write"); + if (($lang_invalid = $value['invalid_language_file_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'failure', + $this->languageKeysToLocalizedList($lang_invalid) . ": " . $this->lng->txt("file_not_valid"), + true + ); + } - ilObjLanguage::refreshAll(); - $this->data = $this->lng->txt("languages_updated"); - $this->out(); + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'info', + $this->languageKeysToLocalizedList($lang_not_installed) + . " " . $this->lng->txt("language_not_installed"), + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** - * update selected languages + * Refresh (already installed) languages, re-seeding their base data + * (plus a customizing/local file if one exists) from the current + * language files - see UpdateLanguage. A language among $ids that is + * not installed is left completely untouched. */ public function refreshSelectedObject(array $ids): void { - $this->checkPermission("write"); - $this->data = $this->lng->txt("selected_languages_updated"); - $this->lng->loadLanguageModule("meta"); + $this->checkPermission('write'); - $refreshed = []; - foreach ($ids as $id) { - $langObj = new ilObjLanguage((int) $id, false); - if ($langObj->refresh()) { - $refreshed[] = $langObj->getKey(); - $this->data .= "
" . $this->lng->txt("meta_l_" . $langObj->getKey()); - } - unset($langObj); + // See installObject() for why an empty selection must never reach + // the Activity itself. + if ($ids === []) { + $this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_checkbox'), true); + $this->ctrl->redirect($this, 'view'); + return; } - ilObjLanguage::refreshPlugins($refreshed); - $this->out(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + + $language_keys = []; + foreach ($ids as $obj_id) { + $language_keys[] = ilObject::_lookupTitle((int) $obj_id); + } + + $result = $this->update_language->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['language_keys' => $language_keys] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message (see uninstallObject()) - a fixed + // "language_not_installed" prefix does not apply to every + // possible error here (e.g. a permission failure, or an invalid + // language file), so it must not be prepended unconditionally. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; + } + $value = $result->value(); + + // Plugin language files are refreshed only for the languages that + // were actually updated - a requested-but-not-installed language + // (see not_installed_language_keys below) has nothing to refresh. + ilObjLanguage::refreshPlugins($value['updated_language_keys']); + + if (($lang_updated = $value['updated_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'success', + $this->lng->txt('selected_languages_updated') . ' ' + . $this->languageKeysToLocalizedList($lang_updated), + true + ); + } + + if (($lang_not_installed = $value['not_installed_language_keys']) !== []) { + $this->tpl->setOnScreenMessage( + 'info', + $this->languageKeysToLocalizedList($lang_not_installed) + . ' ' . $this->lng->txt('language_not_installed'), + true + ); + } + + $this->ctrl->redirect($this, 'view'); } /** @@ -596,19 +863,6 @@ public function executeCommand(): void $this->uninstallObject($ids); break; case 'install': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if (!$langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $this->installObject($ids); - break; case 'install_local': $ids = $this->getIdsFromQueryToken(); if (current($ids) === 'ALL_OBJECTS') { @@ -618,7 +872,12 @@ public function executeCommand(): void $ids[] = (string) $lang["obj_id"]; } } - $this->installLocalObject($ids); + // $action is exactly 'install' or 'install_local' + // here (the two case labels above) - which are + // also InstallLanguage::MODE_INSTALL and + // MODE_INSTALL_LOCAL's values, so it can be + // passed straight through as the mode. + $this->installObject($ids, $action); break; case 'lang_uninstall_changes': $ids = $this->getIdsFromQueryToken(); @@ -714,6 +973,10 @@ public function confirmRefreshSelectedObject(array $a_ids = []): void $this->ctrl->redirect($this, "view"); } + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $conf_screen = new ilConfirmationGUI(); $some_changed = false; foreach ($ids as $id) { @@ -743,10 +1006,16 @@ public function confirmUninstallObject(): void $this->checkPermission("write"); $this->lng->loadLanguageModule("meta"); + + $ids = $this->getIdsFromQueryToken(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $conf_screen = new ilConfirmationGUI(); $conf_screen->setFormAction($this->ctrl->getFormAction($this)); $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_confirm")); - foreach ($this->getIdsFromQueryToken() as $id) { + foreach ($ids as $id) { $lang_title = ilObject::_lookupTitle((int) $id); $conf_screen->addItem("id[]", $id, $this->lng->txt("meta_l_" . $lang_title)); } @@ -760,10 +1029,16 @@ public function confirmUninstallChangesObject(): void $this->checkPermission('write'); $this->lng->loadLanguageModule("meta"); + + $ids = $this->getIdsFromQueryToken(); + if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { + return; + } + $conf_screen = new ilConfirmationGUI(); $conf_screen->setFormAction($this->ctrl->getFormAction($this)); $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_changes_confirm")); - foreach ($this->getIdsFromQueryToken() as $id) { + foreach ($ids as $id) { $lang_title = ilObject::_lookupTitle($id); $conf_screen->addItem("id[]", (string) $id, $this->lng->txt("meta_l_" . $lang_title)); } @@ -772,56 +1047,6 @@ public function confirmUninstallChangesObject(): void $this->tpl->setContent($conf_screen->getHTML()); } - /** - * Get Actions - */ - public function getActions(): array - { - $f = $this->ui_factory; - return [ - 'confirmRefreshSelected' => $f->table()->action()->standard( - $this->lng->txt("refresh"), - $this->url_builder->withParameter($this->action_token, "confirmRefreshSelected"), - $this->id_token - )->withAsync(), - 'install' => $f->table()->action()->standard( - $this->lng->txt("install"), - $this->url_builder->withParameter($this->action_token, "install"), - $this->id_token - ), - 'installLocal' => $f->table()->action()->standard( - $this->lng->txt("install_local"), - $this->url_builder->withParameter($this->action_token, "installLocal"), - $this->id_token - ), - 'confirmUninstall' => $f->table()->action()->standard( - $this->lng->txt("uninstall"), - $this->url_builder->withParameter($this->action_token, "confirmUninstall"), - $this->id_token - ), - 'confirmUninstallChanges' => $f->table()->action()->standard( - $this->lng->txt("lang_uninstall_changes"), - $this->url_builder->withParameter($this->action_token, "confirmUninstallChanges"), - $this->id_token - ), - 'setSystemLanguage' => $f->table()->action()->single( - $this->lng->txt("setSystemLanguage"), - $this->url_builder->withParameter($this->action_token, "setSystemLanguage"), - $this->id_token - ), - 'setUserLanguage' => $f->table()->action()->single( - $this->lng->txt("setUserLanguage"), - $this->url_builder->withParameter($this->action_token, "setUserLanguage"), - $this->id_token - ), - 'editFolder' => $f->table()->action()->single( - $this->lng->txt("edit"), - $this->url_builder->withParameter($this->action_token, "editFolder"), - $this->id_token - ), - ]; - } - protected function editFolderObject(array $ids): void { $this->ctrl->setParameterByClass("ilobjlanguageextgui", "obj_id", current($ids)); @@ -829,21 +1054,60 @@ protected function editFolderObject(array $ids): void } /** - * Disable language detection + * Disable language detection - see SetLanguageDetectionEnabled. */ protected function disableLanguageDetectionObject(): void { - $this->settings->set("lang_detection", '0'); - $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); - $this->viewObject(); + $this->setLanguageDetectionEnabledObject(false); } /** - * Enable language detection + * Enable language detection - see SetLanguageDetectionEnabled. */ protected function enableLanguageDetectionObject(): void { - $this->settings->set("lang_detection", '1'); + $this->setLanguageDetectionEnabledObject(true); + } + + /** + * Common implementation of enableLanguageDetectionObject()/ + * disableLanguageDetectionObject() - both flip the very same + * "lang_detection" system setting to a fixed value, so both are backed + * by the same SetLanguageDetectionEnabled Activity with a different + * boolean argument. + */ + private function setLanguageDetectionEnabledObject(bool $enabled): void + { + $result = $this->set_language_detection_enabled->maybePerformAs( + $this->ui_factory->input(), + $this->current_user_id, + ['enabled' => $enabled] + ); + + if ($result->isError()) { + $error = $result->error(); + $error_message = $this->activityErrorMessage($error); + + // activityErrorMessage() already returns a complete, self- + // contained message - a generic, localized "action_aborted" text + // for an unexpected internal failure, or the concrete, + // already-actionable message for a SafeToDisplayActivityError - + // appending "action_aborted" again would either duplicate it or + // wrongly imply every rejection is a generic abort. + $this->tpl->setOnScreenMessage( + 'failure', + $error_message, + true + ); + + $this->ctrl->redirect($this, 'view'); + return; + } + + // Unlike installObject()/uninstallObject()/refreshSelectedObject(), + // which redirect after a persisted message, this preserves the + // extracted code's original behaviour: render the view directly with + // a non-persisted message, rather than an HTTP redirect. $this->tpl->setOnScreenMessage('success', $this->lng->txt("saved_successfully")); $this->viewObject(); } diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php new file mode 100644 index 000000000000..564c70a331a9 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -0,0 +1,319 @@ +replace_lang_entry = $replace_lang_entry + ?? static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => \ilObjLanguage::replaceLangEntry($module, $identifier, $lang_key, $value, $local_change, $remarks); + $db_resolver = $db instanceof \Closure ? $db : static fn(): \ilDBInterface => $db; + $this->update_module_cache = $update_module_cache + ?? static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use ($db_resolver): void { + $db = $db_resolver(); + + $set = $db->query( + 'SELECT lang_array FROM lng_modules WHERE lang_key = ' + . $db->quote($lang_key, 'text') . ' AND module = ' . $db->quote($module, 'text') + ); + $row = $db->fetchAssoc($set); + if ($row === null || !is_string($row['lang_array'] ?? null)) { + return; + } + + $entries = unserialize($row['lang_array'], ['allowed_classes' => false]); + if (!is_array($entries)) { + return; + } + + $entries[$identifier] = $value; + \ilObjLanguage::replaceLangModule($lang_key, $module, $entries); + }; + $this->user_login = $user_login + ?? static fn(int $usr_id): string => \ilObjUser::_lookupLogin($usr_id); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Adds one new "adjust language variables" entry (a module/identifier pair) to +every currently installed language for which a value was given. An installed +language for which no value (or only a blank one) was given is left +completely untouched. + +"de" and "en" are mandatory whenever they are installed: a missing or blank +value for either rejects the request as a whole - nothing is written for any +language, not even ones with a perfectly valid value given. +MARKDOWN + ); + } + + /** + * Must keep exactly `Activity::getInputDescription(FieldFactory $f): FormInput`'s signature. + * An earlier version added an optional `?array $installed_language_keys` parameter here to + * avoid a second repository round-trip; while PHP itself permits an optional extra parameter, + * a *subclass* overriding the method with the plain interface signature then has FEWER + * parameters than its own parent, which PHP rejects as a variance fatal error at class-load + * time (reproduced with a minimal subclass under PHP 8.5.4). Hence this resolves the + * installed-language snapshot itself, on every call, via the repository directly. + * + * Consequence: getInputDescription() and perform() can observe different installed-language + * snapshots if a language is installed/uninstalled between the two calls within one + * maybePerformAs() call. A newly installed optional language just ends up in + * `skipped_empty_language_keys`; a newly installed "de"/"en" makes perform() reject the whole + * request (fail-closed, since no value could have been submitted for it); a language + * uninstalled in the meantime has its submitted value silently dropped rather than written or + * reported. No case crashes or writes partial data. + */ + public function getInputDescription(FieldFactory $f): FormInput + { + $installed_language_keys = $this->installed_language_repository->getInstalledLanguages(); + + // Field labels use $this->lng->txt('meta_l_' . $lang_key), which requires the caller to + // have already loaded the "meta" language module. This method deliberately does not load + // it itself: loadLanguageModule() merges keys unnamespaced into the shared $lng instance, + // which could clobber another module's keys if this ran generically alongside other + // Activities. The GUI caller already loads it; a caller that doesn't gets the raw, + // untranslated placeholder (e.g. "-meta_l_de-") instead of a crash. + $module = $f->text( + 'Module', + 'Name of the language module the new entry belongs to.' + )->withRequired(true)->withDedicatedName('module'); + + $identifier = $f->text( + 'Identifier', + 'Identifier (topic) of the new language entry.' + )->withRequired(true)->withDedicatedName('identifier'); + + $translation_fields = []; + foreach ($installed_language_keys as $lang_key) { + $is_mandatory = in_array($lang_key, ['de', 'en'], true); + $translation_fields[$lang_key] = $f->text( + $this->lng->txt('meta_l_' . $lang_key) + )->withRequired($is_mandatory)->withDedicatedName($lang_key); + } + + $translations = $f->group( + $translation_fields, + 'Translations', + 'Value of the new entry for each installed language; "de" and "en" are mandatory ' . + '(if installed) - a missing/blank value for either rejects the whole request, ' . + 'nothing is written for any language. Every other installed language is optional ' . + 'and simply skipped if left blank.' + )->withDedicatedName('translations'); + + return $f->group([ + 'module' => $module, + 'identifier' => $identifier, + 'translations' => $translations, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of adding the language entry.'), + [ + 'module' => $f->string($this->markdown('Language module the new entry was added to.')), + 'identifier' => $f->string($this->markdown('Identifier of the new language entry.')), + 'added_language_keys' => $f->list( + $this->markdown( + 'Installed languages for which a non-blank value was given - the entry was ' . + 'written for them.' + ), + $f->string($this->markdown('Language key of a language the entry was added for.')) + ), + 'skipped_empty_language_keys' => $f->list( + $this->markdown( + 'Installed languages for which no value (or only a blank one) was given - ' . + 'left completely untouched, since there is nothing to write for them.' + ), + $f->string($this->markdown('Language key of a language with no given value.')) + ), + ] + ); + } + + /** + * @param mixed $parameters may additionally carry a `usr_id` (int) key, merged in by + * LanguageActivity::maybePerformAs() via additionalPerformParameters() (never part of + * getInputDescription(), so it can never be spoofed via form data): if given, it is + * recorded as the author of the local change made to every written entry. It is + * optional - a generic caller following the plain Activity contract never supplies it, + * and the entries are then written without an attributed author instead. + */ + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $module = $parameters['module'] ?? null; + $identifier = $parameters['identifier'] ?? null; + $translations = $parameters['translations'] ?? null; + $usr_id = $parameters['usr_id'] ?? null; + + if (!is_string($module) || $module === '' + || !is_string($identifier) || $identifier === '' + || !is_array($translations) + || ($usr_id !== null && !is_int($usr_id)) + ) { + throw new InvalidInputException( + 'The module, identifier and translations parameters are required; usr_id, if given, must be an int.' + ); + } + + $installed_language_keys = $this->installed_language_repository->getInstalledLanguages(); + + $missing_mandatory_language_keys = []; + foreach (['de', 'en'] as $mandatory_lang_key) { + if (!in_array($mandatory_lang_key, $installed_language_keys, true)) { + continue; + } + + $value = $translations[$mandatory_lang_key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + + if (!$value) { + $missing_mandatory_language_keys[] = $mandatory_lang_key; + } + } + + if ($missing_mandatory_language_keys !== []) { + throw new InvalidInputException( + 'A value is required for: ' . implode(', ', $missing_mandatory_language_keys) . '.' + ); + } + + // $login is '' when usr_id is absent; replaceLangEntry() treats '' as "no remarks", so + // the entry is written without an attributed author rather than the request failing. + $login = is_int($usr_id) ? ($this->user_login)($usr_id) : ''; + $local_change = gmdate('Y-m-d H:i:s'); + + $added_language_keys = []; + $skipped_empty_language_keys = []; + + // Not transactional across multiple languages: a failure partway through (e.g. a + // database error) leaves languages processed so far written. + foreach ($installed_language_keys as $lang_key) { + $value = $translations[$lang_key] ?? ''; + $value = is_string($value) ? trim($value) : ''; + + // A value of exactly "0" is treated as blank too, matching the legacy behaviour. + if (!$value) { + $skipped_empty_language_keys[] = $lang_key; + continue; + } + + ($this->replace_lang_entry)($module, $identifier, $lang_key, $value, $local_change, $login); + ($this->update_module_cache)($lang_key, $module, $identifier, $value); + + $added_language_keys[] = $lang_key; + } + + return [ + 'module' => $module, + 'identifier' => $identifier, + 'added_language_keys' => $added_language_keys, + 'skipped_empty_language_keys' => $skipped_empty_language_keys, + ]; + } + + /** + * Threads the trusted, server-resolved `usr_id` into perform()'s $parameters via + * LanguageActivity's template-method hook - see LanguageActivity::maybePerformAs()'s + * array_merge() for why this wins over a same-named key from form input. + * + * @return array{usr_id: int} + */ + protected function additionalPerformParameters(int $usr_id): array + { + return ['usr_id' => $usr_id]; + } + + /** + * @param array{module: string, identifier: string, translations: array} $grind_result + * @return array{module: string, identifier: string, translations: array} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'module' => $this->toNonEmptyString($grind_result['module'], 'module'), + 'identifier' => $this->toNonEmptyString($grind_result['identifier'], 'identifier'), + 'translations' => $grind_result['translations'], + ]; + } + + private function toNonEmptyString(mixed $value, string $field): string + { + if (!is_string($value) || trim($value) === '') { + // getInputDescription()'s withRequired(true) only rejects an entirely empty raw + // string, not a whitespace-only one - reject that here too. + throw new InvalidInputException("The $field parameter must be a non-empty string."); + } + + return trim($value); + } +} diff --git a/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php b/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php new file mode 100644 index 000000000000..76d70726d0c7 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/AmbiguousLanguageTitleException.php @@ -0,0 +1,25 @@ +text( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_keys'); + + return $f->group([ + 'language_keys' => $language_keys, + ]); + } + + /** + * @param array{language_keys: string} $grind_result + * @return array{language_keys: list} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_keys' => $this->toLanguageKeyList($grind_result['language_keys']), + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/GrindingInputAccess.php b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php new file mode 100644 index 000000000000..9f5f09583b71 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindingInputAccess.php @@ -0,0 +1,134 @@ +withNameFrom(new FormInputNameSource()); + } + + /** + * @param array $flat_raw_values keyed by the HTML input names assigned via + * named() above + */ + public static function withRawValues(FormInput $named, array $flat_raw_values): FormInput + { + self::assertInternal($named, 'withInput()'); + + /** @var InputInternal&FormInput $named */ + return $named->withInput(new ArrayInputData($flat_raw_values)); + } + + public static function content(FormInput $with_input): Result + { + self::assertInternal($with_input, 'getContent()'); + + /** @var InputInternal $with_input */ + return $with_input->getContent(); + } + + public static function nameOf(Input $input): ?string + { + return $input instanceof InputInternal ? $input->getName() : null; + } + + public static function errorOf(Input $input): ?string + { + return $input instanceof InputInternal ? $input->getError() : null; + } + + public static function isNameable(Input $input): bool + { + return $input instanceof InputInternal; + } + + /** + * Throws $exception_message as a \LogicException unless $input is nameable + * (i.e. implements InputInternal) - kept here so callers never need to name + * InputInternal themselves. + */ + public static function requireNameable(Input $input, string $exception_message): void + { + if (!$input instanceof InputInternal) { + throw new \LogicException($exception_message); + } + } + + /** + * The dedicated name (see `withDedicatedName()`) is only available on the + * concrete implementation base class, not on the public `Input` interface. + */ + public static function dedicatedNameOf(Input $input): ?string + { + return $input instanceof \ILIAS\UI\Implementation\Component\Input\Input + ? $input->getDedicatedName() + : null; + } + + private static function assertInternal(FormInput $input, string $needed_for): void + { + if (!$input instanceof InputInternal) { + throw new \LogicException( + 'Expected a FormInput also implementing ' . InputInternal::class . ' (needed for ' . + $needed_for . '), got: ' . get_debug_type($input) + ); + } + } +} diff --git a/components/ILIAS/Language/src/Activities/GrindsFormInput.php b/components/ILIAS/Language/src/Activities/GrindsFormInput.php new file mode 100644 index 000000000000..2ad7df3b0f1c --- /dev/null +++ b/components/ILIAS/Language/src/Activities/GrindsFormInput.php @@ -0,0 +1,222 @@ + $raw_parameters + */ + private function grind(FormInput $description, array $raw_parameters): Result + { + try { + $named = GrindingInputAccess::named($description, static::class); + + $flat_input = []; + // Unknown keys are tolerated at this top level (getInputDescription()'s outermost + // group is never itself validated against $raw_parameters), but rejected in every + // nested group - see the $enforce_known_keys parameter below. + $this->collectRawValues($named, $raw_parameters, $flat_input); + + $with_input = GrindingInputAccess::withRawValues($named, $flat_input); + $content = GrindingInputAccess::content($with_input); + + if ($content->isError()) { + return new Result\Error($this->describeInputError($with_input)); + } + + return new Result\Ok($content->value()); + } catch (InvalidInputException $e) { + return new Result\Error($e); + } catch (\InvalidArgumentException $e) { + // Every \InvalidArgumentException reachable in this try block - from + // collectRawValues()/normalizeCheckboxRawValue() above, or from the UI framework + // applying $flat_input to an already-built field tree - describes a malformed request + // value, never an internal misconfiguration, and never leaks anything sensitive - safe + // to convert into an InvalidInputException shown to the end user. One thrown while + // BUILDING the FormInput itself happens inside getInputDescription(), in the caller's + // own try block, and never reaches here. + return new Result\Error(new InvalidInputException($e->getMessage(), 0, $e)); + } catch (\Throwable $e) { + return new Result\Error( + $e instanceof \Exception ? $e : new \RuntimeException($e->getMessage(), 0, $e) + ); + } + } + + /** + * @param array $flat + * @param bool $enforce_known_keys Defaults to false for the outermost group (grind() above), + * so an unrelated key in top-level $raw_parameters is silently ignored. Every nested + * group is always recursed into with true (below) instead - its keys come entirely + * from this Activity's own getInputDescription(), so an unknown one should fail loudly + * instead of vanishing. + */ + private function collectRawValues(Input $named, mixed $raw_value, array &$flat, bool $enforce_known_keys = false): void + { + if ($named instanceof GroupInput) { + // null means the group was omitted entirely (treated as an empty array, so every + // child gets null in turn); any other non-array value is a genuine type mismatch and + // must be rejected loudly instead. + if ($raw_value !== null && !is_array($raw_value)) { + throw new InvalidInputException( + 'Expected an array of values (or none at all) for ' + . $this->fieldLabelForErrorMessage($named) . ', got: ' . get_debug_type($raw_value) + ); + } + $sub_raw = $raw_value ?? []; + + if ($enforce_known_keys) { + $unknown_keys = array_diff(array_keys($sub_raw), array_keys($named->getInputs())); + if ($unknown_keys !== []) { + throw new InvalidInputException( + 'Unknown key(s) for ' . $this->fieldLabelForErrorMessage($named) . ': ' + . implode(', ', array_map(static fn(int|string $key): string => (string) $key, $unknown_keys)) + ); + } + } + + foreach ($named->getInputs() as $key => $child) { + $this->collectRawValues($child, $sub_raw[$key] ?? null, $flat, true); + } + return; + } + + GrindingInputAccess::requireNameable( + $named, + 'Every leaf field of a grindable FormInput must implement the UI framework\'s ' . + 'input-processing internals.' + ); + + $name = GrindingInputAccess::nameOf($named); + if ($name === null) { + throw new \LogicException('Every field of a grindable FormInput must have a name.'); + } + + $flat[$name] = match (true) { + $named instanceof Checkbox => $this->normalizeCheckboxRawValue($raw_value), + $named instanceof TextField && is_array($raw_value) => $this->joinListOfStringsRawValue($raw_value), + default => $raw_value ?? '', + }; + } + + /** + * Returns $raw_value joined into a single comma-separated string if every one of its items + * is a string; otherwise returns $raw_value unchanged, in whatever shape the caller passed. + * + * @param array $raw_value + * @return array|string + */ + private function joinListOfStringsRawValue(array $raw_value): array|string + { + foreach ($raw_value as $item) { + if (!is_string($item)) { + return $raw_value; + } + } + + return implode(',', $raw_value); + } + + private function normalizeCheckboxRawValue(mixed $value): string + { + if ( + $value === true || $value === 1 || $value === '1' || $value === 'true' + || $value === 'checked' || $value === 'on' + ) { + return 'checked'; + } + if ( + $value === false || $value === 0 || $value === '0' + || $value === '' || $value === 'false' || $value === null + ) { + return ''; + } + + throw new \InvalidArgumentException( + 'Expected a boolean or one of the common primitive representations of true/false ' . + '(true/false, 1/0, "1"/"0", "true"/"false", "checked"/"on", "" or null), got: ' + . get_debug_type($value) + ); + } + + private function describeInputError(FormInput $with_input): InvalidInputException + { + $field_errors = []; + $this->collectFieldErrors($with_input, $field_errors); + + if ($field_errors === []) { + $error = GrindingInputAccess::errorOf($with_input); + $field_errors[] = $error ?? 'Invalid input.'; + } + + return new InvalidInputException(implode('; ', $field_errors)); + } + + /** + * @param list $field_errors + */ + private function collectFieldErrors(Input $named, array &$field_errors): void + { + if ($named instanceof GroupInput) { + foreach ($named->getInputs() as $child) { + $this->collectFieldErrors($child, $field_errors); + } + return; + } + + if (!GrindingInputAccess::isNameable($named)) { + return; + } + + $error = GrindingInputAccess::errorOf($named); + if ($error !== null) { + $field_errors[] = $this->fieldLabelForErrorMessage($named) . ': ' . $error; + } + } + + private function fieldLabelForErrorMessage(Input $named): string + { + $dedicated_name = GrindingInputAccess::dedicatedNameOf($named); + if ($dedicated_name !== null) { + return $dedicated_name; + } + + return GrindingInputAccess::nameOf($named) ?? '?'; + } +} diff --git a/components/ILIAS/Language/src/Activities/InstallLanguage.php b/components/ILIAS/Language/src/Activities/InstallLanguage.php new file mode 100644 index 000000000000..e2c934a3fb4d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -0,0 +1,257 @@ + throw new \LogicException( + 'RBAC is not available during Setup; ' + . self::class . '::isAllowedToPerform() cannot be used here.' + ), + $setup_language + ); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Installs one or more languages in the ILIAS system, or applies just their +customizing/local language file, depending on the chosen mode. + +Mode "install" fully installs a language that is not yet installed (base +data, plus a customizing/local file if one exists) and leaves an already +installed language completely untouched. Mode "install_local" instead +(re-)applies only the customizing/local file on top of an already installed +language, without touching its base data, and leaves a not-yet-installed +language completely untouched. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $language_keys = $f->text( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_keys'); + + $mode = $f->select( + 'Mode', + [ + self::MODE_INSTALL => 'Install', + self::MODE_INSTALL_LOCAL => 'Install local', + ], + 'Whether to fully install the given languages, or to only ' . + '(re-)apply their customizing/local file on top of an existing ' . + 'installation.' + )->withRequired(true)->withDedicatedName('mode'); + + return $f->group([ + 'language_keys' => $language_keys, + 'mode' => $mode, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language installation.'), + [ + 'installed_language_keys' => $f->list( + $this->markdown('Newly installed languages without a custom language file.'), + $f->string($this->markdown('Language key of a newly installed language.')) + ), + 'installed_with_local_language_keys' => $f->list( + $this->markdown( + 'Languages for which a custom/local language file was (re-)installed - ' . + 'either as part of a fresh installation (mode "install"), or applied on ' . + 'top of an already installed language (mode "install_local").' + ), + $f->string($this->markdown('Language key of a language with an installed custom language file.')) + ), + 'already_installed_language_keys' => $f->list( + $this->markdown( + 'Languages for which this run changed nothing: mode "install" was ' . + 'requested for a language that was already installed (a no-op by design ' . + '- use mode "install_local" to (re-)apply a customizing file instead), ' . + 'or mode "install_local" was requested for an installed language that ' . + 'has no customizing/local file to apply.' + ), + $f->string($this->markdown('Language key of an already installed language.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + 'Languages requested with mode "install_local" that are not installed - ' . + 'skipped entirely, since there is nothing installed yet to apply local ' . + 'changes on top of.' + ), + $f->string($this->markdown('Language key of a not-yet-installed language.')) + ), + 'invalid_local_language_files' => $f->list( + $this->markdown('Local language files with an invalid file name.'), + $f->string($this->markdown('File name of an invalid local language file.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + $mode = $this->toMode($parameters['mode'] ?? null); + + $currently_installed_language_keys = $this->setup_language->getInstalledLanguages(); + + $to_fully_install = []; + $to_apply_local_changes = []; + $already_installed_no_op = []; + $not_installed_no_op = []; + + foreach ($language_keys as $language_key) { + $is_installed = in_array($language_key, $currently_installed_language_keys, true); + if ($mode === self::MODE_INSTALL) { + if ($is_installed) { + $already_installed_no_op[] = $language_key; + } else { + $to_fully_install[] = $language_key; + } + } elseif ($is_installed) { + $to_apply_local_changes[] = $language_key; + } else { + $not_installed_no_op[] = $language_key; + } + } + + $error_language_keys = []; + foreach ($to_fully_install as $language_key) { + if (!$this->setup_language->checkLanguageForInstallation($language_key)) { + $error_language_keys[] = $language_key; + } + } + + if ($error_language_keys !== []) { + throw new \RuntimeException( + 'Invalid language files: ' . implode(', ', $error_language_keys) + ); + } + + $installed_language_keys = []; + $installed_with_local_language_keys = []; + $invalid_local_language_files = []; + + $affected_language_keys = array_merge($to_fully_install, $to_apply_local_changes); + if ($affected_language_keys !== []) { + $db_languages = $this->setup_language->getAvailableLanguagesForInstallation(); + $local_language_keys = $this->setup_language->getLocalLanguages(); + $invalid_local_language_files = $this->setup_language->getInvalidLocalLanguageFiles( + $affected_language_keys + ); + + foreach ($to_fully_install as $language_key) { + $this->setup_language->flushLanguageForInstallation($language_key); + $this->setup_language->insertLanguageForInstallation($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + + if (in_array($language_key, $local_language_keys, true)) { + $installed_with_local_language_keys[] = $language_key; + } else { + $installed_language_keys[] = $language_key; + } + } + + foreach ($to_apply_local_changes as $language_key) { + $this->setup_language->insertLanguageForApplyingLocalChanges($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + + if (in_array($language_key, $local_language_keys, true)) { + $installed_with_local_language_keys[] = $language_key; + } else { + // "install_local" had no customizing/local file to apply for this language - + // the same outcome as "already installed, nothing changed". + $already_installed_no_op[] = $language_key; + } + } + } + + return [ + 'installed_language_keys' => $installed_language_keys, + 'installed_with_local_language_keys' => $installed_with_local_language_keys, + 'already_installed_language_keys' => $already_installed_no_op, + 'not_installed_language_keys' => $not_installed_no_op, + 'invalid_local_language_files' => $invalid_local_language_files, + ]; + } + + private function toMode(mixed $value): string + { + if ($value === self::MODE_INSTALL || $value === self::MODE_INSTALL_LOCAL) { + return $value; + } + + throw new InvalidInputException( + 'mode must be either "' . self::MODE_INSTALL . '" or "' . self::MODE_INSTALL_LOCAL . '".' + ); + } + + /** + * @param array{language_keys: string, mode: string} $grind_result + * @return array{language_keys: list, mode: string} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_keys' => $this->toLanguageKeyList($grind_result['language_keys']), + 'mode' => $this->toMode($grind_result['mode']), + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/InvalidInputException.php b/components/ILIAS/Language/src/Activities/InvalidInputException.php new file mode 100644 index 000000000000..c4c476c24dde --- /dev/null +++ b/components/ILIAS/Language/src/Activities/InvalidInputException.php @@ -0,0 +1,25 @@ +refinery = $refinery; + $this->lng = $language; + $this->rbac_system = $rbac_system instanceof \Closure + ? $rbac_system + : static fn(): \ilRbacSystem => $rbac_system; + $this->language_folder_ref_id = $language_folder_ref_id instanceof \Closure + ? $language_folder_ref_id + : static fn(): int => $language_folder_ref_id; + } + + public function getType(): ActivityType + { + return ActivityType::Command; + } + + protected function markdown(string $raw): Text\SimpleDocumentMarkdown + { + return new Text\SimpleDocumentMarkdown( + new SimpleDocumentMarkdownShape( + $this->refinery->string()->markdown() + ), + $raw + ); + } + + public function isAllowedToPerform(int $usr_id, mixed $parameters): bool + { + return ($this->rbac_system)()->checkAccessOfUser( + $usr_id, + 'write', + ($this->language_folder_ref_id)() + ); + } + + public function maybePerformAs(InputFactory $input_factory, int $usr_id, array $raw_parameters): Result + { + try { + // getInputDescription()/$input_factory->field() might throw too (e.g. a concrete + // Activity building its FormInput eagerly) - kept inside the try so it is wrapped in + // a Result\Error like everything else, per Activity::maybePerformAs()'s contract, + // instead of escaping uncaught. + $grind_result = $this->grind($this->getInputDescription($input_factory->field()), $raw_parameters); + if ($grind_result->isError()) { + return new Result\Error($grind_result->error()); + } + + $parameters = $this->normalizeParameters($grind_result->value()); + if (!$this->isAllowedToPerform($usr_id, $parameters)) { + return new Result\Error($this->lng->txt('msg_no_perm_write')); + } + + // array_merge(), not `+`: `+` keeps the LEFT operand on a key collision, so a spoofed + // 'usr_id' from form input would win over the trusted value from + // additionalPerformParameters(). array_merge() keeps the RIGHT (last) operand instead + // - safe here since normalizeParameters()'s contract below is `array`, + // never a numeric(-string) key, which is the one case array_merge() would not + // overwrite by key. + + return new Result\Ok( + $this->perform(array_merge($parameters, $this->additionalPerformParameters($usr_id))) + ); + } catch (\Throwable $e) { + return new Result\Error( + $e instanceof \Exception ? $e : new \RuntimeException($e->getMessage(), 0, $e) + ); + } + } + + /** + * @return array + */ + abstract protected function normalizeParameters(array $grind_result): array; + + /** + * Template-method hook: lets a subclass thread trusted, server-resolved extras (e.g. the + * caller's `usr_id`) into perform()'s $parameters without overriding maybePerformAs() itself. + * Returned keys win over a same-named key from normalizeParameters() - see the array_merge() + * call above. + * + * @return array + */ + protected function additionalPerformParameters(int $usr_id): array + { + return []; + } +} diff --git a/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php new file mode 100644 index 000000000000..962addfb473d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ParsesLanguageKeyList.php @@ -0,0 +1,71 @@ +.lang` file naming convention. + private const LANGUAGE_KEY_FORMAT = '/^[a-z]{2}$/'; + + /** + * @return list + */ + private function toLanguageKeyList(mixed $value): array + { + if (!is_string($value) && !is_array($value)) { + throw new InvalidInputException('language_keys must be a string or an array of strings.'); + } + + $values = is_array($value) ? $value : [$value]; + $language_keys = []; + + foreach ($values as $item) { + if (!is_string($item)) { + throw new InvalidInputException('language_keys must be a string or an array of strings.'); + } + + foreach (explode(',', (string) $item) as $language_key) { + $language_key = trim($language_key); + if ($language_key === '') { + continue; + } + + if (preg_match(self::LANGUAGE_KEY_FORMAT, $language_key) !== 1) { + throw new InvalidInputException( + 'Invalid language key "' . $language_key . '": a language key must be ' . + 'exactly two lowercase ASCII letters (e.g. "de", "en", "fr").' + ); + } + + if (!in_array($language_key, $language_keys, true)) { + $language_keys[] = $language_key; + } + } + } + + if ($language_keys === []) { + throw new InvalidInputException('At least one language key is required.'); + } + + return $language_keys; + } +} diff --git a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php new file mode 100644 index 000000000000..47dbce3f4e08 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -0,0 +1,160 @@ + + * @param \Closure|null $obj_language_factory (int $obj_id): \ilObjLanguage + */ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + int|\Closure $language_folder_ref_id = 0, + ?\Closure $lng_objects = null, + ?\Closure $obj_language_factory = null, + ) { + parent::__construct($refinery, $language, $rbac_system, $language_folder_ref_id); + $this->lng_objects = $lng_objects + ?? static fn(): array => \ilObject::_getObjectsByType('lng'); + $this->obj_language_factory = $obj_language_factory + ?? static fn(int $obj_id): \ilObjLanguage => new \ilObjLanguage($obj_id, false); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Removes all local changes of one or more already installed languages - both +entries edited directly via the "adjust language variables" table and any +override coming from a customizing/local language file - and reinstalls each +language purely from the global/component language files. + +A language is left completely untouched, and reported separately, if it is +not installed (or not a known language key at all), or if its underlying +language file fails validation. +MARKDOWN + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of removing local language changes.'), + [ + 'removed_local_changes_language_keys' => $f->list( + $this->markdown( + 'Already installed languages whose local changes were removed - they were ' . + 'reinstalled purely from the global/component language files.' + ), + $f->string($this->markdown('Language key of a language whose local changes were removed.')) + ), + 'invalid_language_file_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because their underlying ' . + 'language file failed validation.' + ), + $f->string($this->markdown('Language key of a language with an invalid language file.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because they are not ' . + 'installed (or not a known language key at all) - there is nothing to ' . + 'remove local changes from for them.' + ), + $f->string($this->markdown('Language key of a not-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + [$obj_id_by_language_key, $ambiguous_language_keys] = $this->resolveObjIdsByLanguageKey(); + + // Checked upfront, for all requested keys at once, so a request naming both an + // unambiguous and an ambiguous key never changes the unambiguous one before + // rejecting the whole call. + $requested_ambiguous_language_keys = array_values( + array_intersect($language_keys, array_keys($ambiguous_language_keys)) + ); + if ($requested_ambiguous_language_keys !== []) { + throw new AmbiguousLanguageTitleException( + 'Multiple language objects share the title(s) "' + . implode('", "', $requested_ambiguous_language_keys) + . '" - cannot unambiguously resolve which one(s) to remove local changes from.' + ); + } + + $removed_local_changes_language_keys = []; + $invalid_language_file_keys = []; + $not_installed_language_keys = []; + + // Not transactional across multiple keys: a failure partway through (e.g. a database + // error) leaves languages processed so far changed. + foreach ($language_keys as $language_key) { + if (!array_key_exists($language_key, $obj_id_by_language_key)) { + $not_installed_language_keys[] = $language_key; + continue; + } + + $language_object = ($this->obj_language_factory)($obj_id_by_language_key[$language_key]); + + if (!$language_object->isInstalled()) { + $not_installed_language_keys[] = $language_key; + continue; + } + + // removeLocalChanges() re-checks isInstalled() internally (and additionally + // validates the language file) and returns false if either fails - "not installed" + // is already excluded above, so false here can only mean validation failed. + if ($language_object->removeLocalChanges()) { + $removed_local_changes_language_keys[] = $language_key; + } else { + $invalid_language_file_keys[] = $language_key; + } + } + + return [ + 'removed_local_changes_language_keys' => $removed_local_changes_language_keys, + 'invalid_language_file_keys' => $invalid_language_file_keys, + 'not_installed_language_keys' => $not_installed_language_keys, + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php new file mode 100644 index 000000000000..75d652f132fe --- /dev/null +++ b/components/ILIAS/Language/src/Activities/ResolvesLanguageKeysToObjIds.php @@ -0,0 +1,59 @@ +) + // - left to each using class (rather than declared here) since its default value differs + // per Activity. + + /** + * Language keys (titles) shared by more than one "lng" object are excluded from the first + * array and returned as keys of the second instead. + * + * @return array{0: array, 1: array} + */ + private function resolveObjIdsByLanguageKey(): array + { + $lng_objects = ($this->lng_objects)(); + + $title_occurrences = []; + foreach ($lng_objects as $lng_object) { + $title_occurrences[$lng_object['title']] = ($title_occurrences[$lng_object['title']] ?? 0) + 1; + } + + $obj_id_by_language_key = []; + $ambiguous_language_keys = []; + foreach ($lng_objects as $lng_object) { + $title = $lng_object['title']; + if ($title_occurrences[$title] > 1) { + $ambiguous_language_keys[$title] = true; + continue; + } + + $obj_id_by_language_key[$title] = (int) $lng_object['obj_id']; + } + + return [$obj_id_by_language_key, $ambiguous_language_keys]; + } +} diff --git a/components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php b/components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php new file mode 100644 index 000000000000..d99e0db265f8 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SafeToDisplayActivityError.php @@ -0,0 +1,25 @@ +settings = $settings instanceof \Closure + ? $settings + : static fn(): Setting => $settings; + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Enables or disables the system-wide automatic language detection from the +browser's Accept-Language header. This is a single on/off flag for the +whole installation, not a per-language or per-user setting. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $enabled = $f->checkbox( + 'Enabled', + 'Whether automatic language detection from the browser should be enabled.' + )->withDedicatedName('enabled'); + + return $f->group([ + 'enabled' => $enabled, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of changing the language detection setting.'), + [ + 'enabled' => $f->bool( + $this->markdown('The language detection setting after this change.') + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters) || !array_key_exists('enabled', $parameters) || !is_bool($parameters['enabled'])) { + throw new InvalidInputException('The enabled parameter (bool) is required.'); + } + + $enabled = $parameters['enabled']; + + ($this->settings)()->set('lang_detection', $enabled ? '1' : '0'); + + return ['enabled' => $enabled]; + } + + /** + * @param array{enabled: bool} $grind_result + * @return array{enabled: bool} + */ + protected function normalizeParameters(array $grind_result): array + { + return ['enabled' => $grind_result['enabled']]; + } +} diff --git a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php new file mode 100644 index 000000000000..f31369e11d08 --- /dev/null +++ b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php @@ -0,0 +1,154 @@ +settings = $settings instanceof \Closure + ? $settings + : static fn(): Setting => $settings; + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Enables or disables the "page translation" feature for one specific +language. This is a per-language on/off flag, not a system-wide setting - +use SetLanguageDetectionEnabled for the system-wide automatic language +detection flag instead. +MARKDOWN + ); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + $language_key = $f->text( + 'Language key', + 'Language key the page translation setting applies to, e.g. de, fr, it.' + )->withRequired(true)->withDedicatedName('language_key'); + + $enabled = $f->checkbox( + 'Enabled', + 'Whether page translation should be enabled for this language.' + )->withDedicatedName('enabled'); + + return $f->group([ + 'language_key' => $language_key, + 'enabled' => $enabled, + ]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of changing the page translation setting.'), + [ + 'language_key' => $f->string( + $this->markdown('Language key the page translation setting was changed for.') + ), + 'enabled' => $f->bool( + $this->markdown('The page translation setting for this language after this change.') + ), + 'changed' => $f->bool( + $this->markdown( + 'Whether the setting actually differed from its previously stored value - ' . + 'false if the requested value already matched the stored one, in which case ' . + 'nothing was written.' + ) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters) + || !array_key_exists('language_key', $parameters) + || !is_string($parameters['language_key']) + || trim($parameters['language_key']) === '' + || !array_key_exists('enabled', $parameters) + || !is_bool($parameters['enabled']) + ) { + throw new InvalidInputException( + 'The language_key (non-empty string) and enabled (bool) parameters are required.' + ); + } + + $language_key = trim($parameters['language_key']); + $enabled = $parameters['enabled']; + + if (!in_array($language_key, $this->installed_language_repository->getInstalledLanguages(), true)) { + throw new InvalidInputException( + 'Unknown language key "' . $language_key . '" - not an installed language.' + ); + } + + $translate_key = 'lang_translate_' . $language_key; + + $currently_enabled = (bool) ($this->settings)()->get($translate_key, '0'); + // Strict comparison of two proper booleans, not a loose `!=` on raw strings - see + // SetLanguageTranslationEnabledTest for the edge case this pins down. + $changed = $currently_enabled !== $enabled; + + if ($changed) { + ($this->settings)()->set($translate_key, $enabled ? '1' : '0'); + } + + return [ + 'language_key' => $language_key, + 'enabled' => $enabled, + 'changed' => $changed, + ]; + } + + /** + * @param array{language_key: string, enabled: bool} $grind_result + * @return array{language_key: string, enabled: bool} + */ + protected function normalizeParameters(array $grind_result): array + { + return [ + 'language_key' => trim($grind_result['language_key']), + 'enabled' => $grind_result['enabled'], + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/UninstallLanguage.php b/components/ILIAS/Language/src/Activities/UninstallLanguage.php new file mode 100644 index 000000000000..5900d8cad8cf --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -0,0 +1,172 @@ + + * @param \Closure|null $obj_language_factory (int $obj_id): \ilObjLanguage + */ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + int|\Closure $language_folder_ref_id = 0, + ?\Closure $lng_objects = null, + ?\Closure $obj_language_factory = null, + ) { + parent::__construct($refinery, $language, $rbac_system, $language_folder_ref_id); + $this->lng_objects = $lng_objects + ?? static fn(): array => \ilObject::_getObjectsByType('lng'); + $this->obj_language_factory = $obj_language_factory + ?? static fn(int $obj_id): \ilObjLanguage => new \ilObjLanguage($obj_id); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Uninstalls one or more already installed languages, flushing their base data +(and any customizing/local data) and resetting the status of the according +language to "not installed". Any user preference still pointing at an +uninstalled language is reset to the system default. + +A language is left completely untouched, and reported separately, if it is +not installed, if it is the current system language, or if it is the +language currently in use by the acting session - none of these may ever be +uninstalled. +MARKDOWN + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language uninstallation.'), + [ + 'uninstalled_language_keys' => $f->list( + $this->markdown('Languages that were uninstalled.'), + $f->string($this->markdown('Language key of an uninstalled language.')) + ), + 'system_language_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because they are the ' . + 'current system language - the system language can never be uninstalled.' + ), + $f->string($this->markdown('Language key of the system language.')) + ), + 'user_language_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because they are the ' . + 'language currently in use by the acting session - a language currently ' . + 'in use can never be uninstalled.' + ), + $f->string($this->markdown('Language key of the language currently in use.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + 'Requested languages that were left untouched because they are not ' . + 'installed (or not a known language key at all) - there is nothing to ' . + 'uninstall for them.' + ), + $f->string($this->markdown('Language key of a not-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + [$obj_id_by_language_key, $ambiguous_language_keys] = $this->resolveObjIdsByLanguageKey(); + + // Checked upfront, for all requested keys at once, so a request naming both an + // unambiguous and an ambiguous key never uninstalls the unambiguous one before + // rejecting the whole call. + $requested_ambiguous_language_keys = array_values( + array_intersect($language_keys, array_keys($ambiguous_language_keys)) + ); + if ($requested_ambiguous_language_keys !== []) { + throw new AmbiguousLanguageTitleException( + 'Multiple language objects share the title(s) "' + . implode('", "', $requested_ambiguous_language_keys) + . '" - cannot unambiguously resolve which one(s) to uninstall.' + ); + } + + $uninstalled_language_keys = []; + $system_language_keys = []; + $user_language_keys = []; + $not_installed_language_keys = []; + + // Not transactional across multiple keys: a failure partway through (e.g. a database + // error) leaves languages processed so far uninstalled. + foreach ($language_keys as $language_key) { + if (!array_key_exists($language_key, $obj_id_by_language_key)) { + $not_installed_language_keys[] = $language_key; + continue; + } + + $language_object = ($this->obj_language_factory)($obj_id_by_language_key[$language_key]); + + if ($language_object->isSystemLanguage()) { + $system_language_keys[] = $language_key; + } elseif ($language_object->isUserLanguage()) { + // Compares against the ambient Language service's lang_user (this request's + // session), not against $usr_id - a caller acting for another user's $usr_id + // (e.g. a webservice or background job) does not protect that user's language. + $user_language_keys[] = $language_key; + } elseif (!$language_object->isInstalled()) { + $not_installed_language_keys[] = $language_key; + } elseif ($language_object->uninstall() !== '') { + // uninstall() re-checks these same guards internally and returns "" if any + // still applies - the outcome is decided by this return value, not the checks + // above alone. + $uninstalled_language_keys[] = $language_key; + } else { + $not_installed_language_keys[] = $language_key; + } + } + + return [ + 'uninstalled_language_keys' => $uninstalled_language_keys, + 'system_language_keys' => $system_language_keys, + 'user_language_keys' => $user_language_keys, + 'not_installed_language_keys' => $not_installed_language_keys, + ]; + } +} diff --git a/components/ILIAS/Language/src/Activities/UpdateLanguage.php b/components/ILIAS/Language/src/Activities/UpdateLanguage.php new file mode 100644 index 000000000000..937540e1792e --- /dev/null +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -0,0 +1,141 @@ + throw new \LogicException( + 'RBAC is not available during Setup; ' + . self::class . '::isAllowedToPerform() cannot be used here.' + ), + $setup_language + ); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown( + <<<'MARKDOWN' +Refreshes one or more already installed languages, re-seeding their base +data (plus a customizing/local file if one exists) from the current +language files. A language that is not installed is left completely +untouched - use InstallLanguage to install it first. +MARKDOWN + ); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + return $f->object( + $this->markdown('Result of the language update.'), + [ + 'updated_language_keys' => $f->list( + $this->markdown( + 'Already installed languages that were refreshed from the current ' . + 'language files (base data, plus a customizing/local file if one exists).' + ), + $f->string($this->markdown('Language key of a refreshed language.')) + ), + 'not_installed_language_keys' => $f->list( + $this->markdown( + 'Requested languages that are not installed - skipped entirely, since ' . + 'there is nothing installed yet to refresh. Use InstallLanguage to ' . + 'install them first.' + ), + $f->string($this->markdown('Language key of a not-yet-installed language.')) + ), + ] + ); + } + + public function perform(mixed $parameters): array + { + if (!is_array($parameters)) { + throw new InvalidInputException('Parameters must be an array.'); + } + + $language_keys = $this->toLanguageKeyList($parameters['language_keys'] ?? null); + + $currently_installed_language_keys = $this->setup_language->getInstalledLanguages(); + + $to_update = []; + $not_installed_no_op = []; + + foreach ($language_keys as $language_key) { + if (in_array($language_key, $currently_installed_language_keys, true)) { + $to_update[] = $language_key; + } else { + $not_installed_no_op[] = $language_key; + } + } + + $error_language_keys = []; + foreach ($to_update as $language_key) { + if (!$this->setup_language->checkLanguageForInstallation($language_key)) { + $error_language_keys[] = $language_key; + } + } + + if ($error_language_keys !== []) { + throw new \RuntimeException( + 'Invalid language files: ' . implode(', ', $error_language_keys) + ); + } + + if ($to_update !== []) { + $db_languages = $this->setup_language->getAvailableLanguagesForInstallation(); + $local_language_keys = $this->setup_language->getLocalLanguages(); + + foreach ($to_update as $language_key) { + $this->setup_language->flushLanguageForInstallation($language_key); + $this->setup_language->insertLanguageForInstallation($language_key); + $this->setup_language->registerInstalledLanguage($language_key, $db_languages, $local_language_keys); + } + } + + return [ + 'updated_language_keys' => $to_update, + 'not_installed_language_keys' => $not_installed_no_op, + ]; + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php new file mode 100644 index 000000000000..f1705d8a3e97 --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/ComponentLanguageFileDirectory.php @@ -0,0 +1,68 @@ + + */ +class ComponentLanguageFileDirectory implements LanguageFileDirectory +{ + private string $base_directory; + + public function __construct( + private readonly Component $component, + private readonly string $prefix, + private readonly string $path_inside_component = 'lang/' + ) { + } + + public function getPrefix(): string + { + return $this->prefix; + } + + public function getPath(): string + { + // $this->component's file location never changes for the lifetime + // of this object, so the reflection/realpath work below only needs + // to happen once - cache it instead of redoing it on every call + // (this method is called repeatedly per directory-listing loop). + if (!isset($this->base_directory)) { + $reflector = new \ReflectionClass($this->component); + $ilias_base_dir = (string) realpath(__DIR__ . '/../../../../../'); + $this->base_directory = str_replace($ilias_base_dir . '/', '', dirname($reflector->getFileName())); + } + + return $this->base_directory . '/' . $this->path_inside_component; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php new file mode 100644 index 000000000000..06ec277b73ee --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php @@ -0,0 +1,30 @@ + + */ +class LanguageFileDirectoryManager +{ + /** + * @var LanguageFileDirectory[] + */ + private array $directories; + + public function __construct( + private readonly LanguageFileDirectory $local_directory, + LanguageFileDirectory ...$global_directories + ) { + $this->directories = $global_directories; + $this->check(); + } + + private function check(): void + { + if ($this->local_directory instanceof CustomizingLanguageFileDirectory) { + if (!$this->local_directory->getPath()) { + throw new \InvalidArgumentException( + "CustomizingLanguageFileDirectory must have a non-empty path" + ); + } + if ($this->local_directory->getPrefix() !== '') { + throw new \InvalidArgumentException( + "CustomizingLanguageFileDirectory must have an empty prefix" + ); + } + } + $main_files = 0; + $prefixes = []; + foreach ($this->directories as $d) { + switch (true) { + case $d instanceof MainLanguageFileDirectory: + $main_files++; + if ($main_files > 1) { + throw new \InvalidArgumentException( + "There must not be more than one MainLanguageFileDirectory" + ); + } + break; + case $d instanceof ComponentLanguageFileDirectory: + if (empty($d->getPrefix())) { + throw new \InvalidArgumentException( + "ComponentLanguageFileDirectory must have a non-empty prefix" + ); + } + if (isset($prefixes[$d->getPrefix()])) { + throw new \InvalidArgumentException( + "There must not be two ComponentLanguageFileDirectory with the same prefix" + ); + } + $prefixes[$d->getPrefix()] = true; + break; + default: + if (empty($d->getPrefix())) { + throw new \InvalidArgumentException("LanguageFileDirectory must have a non-empty prefix"); + } + break; + } + } + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getDirectories(): \Generator + { + yield from $this->directories; + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getCustomizingDirectories(): \Generator + { + yield $this->local_directory; + } + + /** + * @return \Generator|LanguageFileDirectory[] + */ + public function getAllDirectories(): \Generator + { + yield from $this->getDirectories(); + yield from $this->getCustomizingDirectories(); + } +} diff --git a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php new file mode 100644 index 000000000000..0804931dde37 --- /dev/null +++ b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php @@ -0,0 +1,48 @@ + + */ +class MainLanguageFileDirectory implements LanguageFileDirectory +{ + public function getPrefix(): string + { + return ''; // the files already contain the prefixes. This special case is handled inside ilSetupLanguage + } + + public function getPath(): string + { + return 'lang/'; + } + + public function getSuffix(): string + { + return ''; + } + + public function isLocal(): bool + { + return false; + } +} diff --git a/components/ILIAS/Language/src/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php new file mode 100644 index 000000000000..94aca01660b9 --- /dev/null +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -0,0 +1,111 @@ +activity_error_logger` exactly once, in its own + * constructor - the same "resolved once in the constructor, never + * reached into via global $DIC from an action method" idiom already + * used for every Activity these GUI classes inject (see e.g. + * ilObjLanguageFolderGUI::__construct()); and + * - provide `$this->lng` (both ilObjLanguageFolderGUI/ilObjLanguageExtGUI + * already do, inherited from ilObjectGUI). + */ +trait RendersActivityErrors +{ + private readonly \ilLogger $activity_error_logger; + + /** + * Turns a maybePerformAs() Result\Error's error() into text safe to show + * to the user via setOnScreenMessage() (which renders it as HTML, + * unescaped - see every GUI caller's own literal "
" concatenations + * alongside $this->lng->txt() calls). + * - A plain string (e.g. `msg_no_perm_write`) is already a localized, + * trusted, HTML-safe message produced by the Activity itself (via + * $this->lng->txt(), the same source every GUI caller's own literal + * markup already trusts) and is returned as-is, unescaped. + * - A \Throwable implementing SafeToDisplayActivityError (e.g. + * InvalidInputException, AmbiguousLanguageTitleException - both marker + * implementations with no logic of their own) is a genuine, actionable + * problem already reduced to a concrete, non-sensitive message - but, + * unlike the plain-string case above, its text may embed raw, + * caller-controlled data (a language key, an unknown form field + * name, ...): the Activity/domain layer deliberately returns this + * unescaped (see e.g. UninstallLanguage::perform()), so THIS is the + * one place that escapes it for safe HTML display, rather than each + * Activity escaping it itself. + * - Any OTHER \Throwable is an unexpected failure (e.g. a database + * error) whose message is hardcoded English, not meant for end + * users, and in the worst case (\ilDatabaseException) may even + * carry raw SQL - so it is never shown directly: its full cause + * chain (class, message and stack trace of the exception itself, + * plus of every getPrevious() wrapped underneath it) is logged via + * this component's established "lang" logger channel, and a + * generic, localized message (again already trusted, see above) is + * shown instead. + */ + private function activityErrorMessage(\Throwable|string $error): string + { + if (is_string($error)) { + return $error; + } + + if ($error instanceof SafeToDisplayActivityError) { + // ENT_SUBSTITUTE: without it, invalid UTF-8 in getMessage() (e.g. + // caller-controlled data embedded by the domain layer, see the + // docblock above) makes htmlspecialchars() return an empty + // string instead of the message - the user would then see a + // blank error box with no indication anything went wrong, rather + // than the actual (partially substituted) message. + return htmlspecialchars($error->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE); + } + + $this->activity_error_logger->error($this->describeThrowableChain($error)); + + return $this->lng->txt('action_aborted'); + } + + /** + * Renders $error and, recursively, every exception it was caused by + * (getPrevious()) - so a wrapper exception (e.g. the + * \RuntimeException/InvalidInputException that GrindsFormInput::grind() + * wraps a lower-level \Throwable in) never hides the original failure's + * own class, message and trace from the log. + */ + private function describeThrowableChain(\Throwable $error): string + { + $descriptions = []; + for ($current = $error; $current !== null; $current = $current->getPrevious()) { + $descriptions[] = get_class($current) . ': ' . $current->getMessage() . "\n" . $current->getTraceAsString(); + } + + return implode("\nCaused by:\n", $descriptions); + } +} diff --git a/components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php b/components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php new file mode 100644 index 000000000000..30c32119c2d6 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstalledLanguageDatabaseRepository.php @@ -0,0 +1,343 @@ +.lang...` file naming + * convention that getLocalLanguages()/getInstallableLanguages() below + * discover files by. Mirrors + * \ILIAS\Language\Activities\ParsesLanguageKeyList::LANGUAGE_KEY_FORMAT + * (exactly two lowercase ASCII letters) so that a file this repository + * lists as installable/local can never fail that later, stricter + * validation once an Activity actually tries to install/refresh it. + * Kept as a parallel definition, not a shared reference: PHP trait + * constants cannot be accessed via the trait's own name (only through a + * class that uses the trait), and this class has no reason to use + * ParsesLanguageKeyList itself, which exists to parse a + * comma-separated form/request value, not to discover files. + */ + private const LANGUAGE_KEY_PATTERN = '[a-z]{2}'; + + /** + * @param \ilDBInterface|\Closure():\ilDBInterface $db Accepted as a closure so this + * can be built once (e.g. in Language.php's DI wiring) before a database + * connection is available, and resolved lazily on first use - mirroring + * the $GLOBALS['ilDB'] ?? $DIC->database() convention used for + * \ILIAS\Language\Activities\InstallLanguage. + */ + public function __construct( + private readonly \ilDBInterface|\Closure $db, + private readonly LanguageFileDirectoryManager $language_file_directory_manager, + private readonly string $absolute_path, + ) { + } + + private function db(): \ilDBInterface + { + return $this->db instanceof \Closure ? ($this->db)() : $this->db; + } + + public function getInstalledLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + $query = "SELECT title FROM object_data " . + "WHERE type = " . $ilDB->quote("lng", "text") . " " . + "AND " . $ilDB->like("description", "text", "installed%"); + $r = $ilDB->query($query); + + while ($row = $ilDB->fetchObject($r)) { + $arr[] = $row->title; + } + return $arr; + } + + public function getInstalledLocalLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + $query = "SELECT title FROM object_data " . + "WHERE type = " . $ilDB->quote("lng", "text") . " " . + "AND description = " . $ilDB->quote("installed_local", "text"); + $r = $ilDB->query($query); + + while ($row = $ilDB->fetchObject($r)) { + $arr[] = $row->title; + } + return $arr; + } + + public function getAvailableLanguages(): array + { + $ilDB = $this->db(); + + $arr = []; + // Unlike the two queries above, obj_id and description are both + // actually read below (title alone would not be enough here) - see + // the loop body. + $query = "SELECT obj_id, title, description FROM object_data " . + "WHERE type = " . $ilDB->quote("lng", "text"); + $r = $ilDB->query($query); + + while ($row = $ilDB->fetchObject($r)) { + $arr[$row->title]["obj_id"] = $row->obj_id; + $arr[$row->title]["status"] = $row->description; + } + + return $arr; + } + + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array + { + $ilDB = $this->db(); + + if ($min_date === "") { + $min_date = "1980-01-01 00:00:00"; + } + if ($max_date === "") { + $max_date = "2200-01-01 00:00:00"; + } + + $q = sprintf( + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s " . + "AND local_change >= %s AND local_change <= %s", + $ilDB->quote($lang_key, "text"), + $ilDB->quote($min_date, "timestamp"), + $ilDB->quote($max_date, "timestamp") + ); + $result = $ilDB->query($q); + + $changes = []; + while ($row = $result->fetchRow(\ilDBConstants::FETCHMODE_ASSOC)) { + $changes[$row["module"]][$row["identifier"]] = $row["value"]; + } + return $changes; + } + + public function getLanguageEntries(string $lang_key): array + { + $ilDB = $this->db(); + + $q = sprintf( + "SELECT module, identifier, value FROM lng_data WHERE lang_key = %s", + $ilDB->quote($lang_key, "text") + ); + $result = $ilDB->query($q); + + $entries = []; + while ($row = $result->fetchRow(\ilDBConstants::FETCHMODE_ASSOC)) { + $entries[$row["module"]][$row["identifier"]] = $row["value"]; + } + return $entries; + } + + public function getLocalLanguages(): array + { + $local_langs = []; + $working_dir = getcwd(); + + // See LanguageInstallationManager::insertLanguage() for why every + // chdir() here must be undone even if something throws in between - + // a long-lived worker process would otherwise leak a broken cwd into + // unrelated later requests. + try { + foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + if (is_dir($path)) { + $d = dir($path); + chdir($path); + while ($entry = $d->read()) { + if (is_file($entry) && (preg_match("~(^ilias_" . self::LANGUAGE_KEY_PATTERN . "\.lang" . preg_quote($directory->getSuffix(), "~") . "$)~", $entry))) { + $lang_key = substr($entry, 6, 2); + $local_langs[] = $lang_key; + } + } + chdir($working_dir); + } + } + } finally { + chdir($working_dir); + } + + return array_unique($local_langs); + } + + public function getInstallableLanguages(): array + { + $installableLanguages = []; + $working_dir = getcwd(); + + // See LanguageInstallationManager::insertLanguage() for why every + // chdir() here must be undone even if something throws in between. + try { + foreach ($this->language_file_directory_manager->getDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + if (is_dir($path)) { + $d = dir($path); + chdir($path); + while ($entry = $d->read()) { + if (is_file($entry) && (preg_match("~(^ilias_" . self::LANGUAGE_KEY_PATTERN . "\.lang" . preg_quote($directory->getSuffix(), "~") . "$)~", $entry))) { + $lang_key = substr($entry, 6, 2); + $installableLanguages[] = $lang_key; + } + } + chdir($working_dir); + } + } + } finally { + chdir($working_dir); + } + + return array_unique($installableLanguages); + } + + public function getInvalidLocalLanguageFiles(array $language_keys): array + { + $invalid_files = []; + $language_keys = array_values(array_unique($language_keys)); + + foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + if (!is_dir($path)) { + continue; + } + + $entries = scandir($path); + if ($entries === false) { + continue; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || !is_file($path . DIRECTORY_SEPARATOR . $entry)) { + continue; + } + + foreach ($language_keys as $language_key) { + $expected_file = 'ilias_' . $language_key . '.lang' . $directory->getSuffix(); + if (str_starts_with($entry, 'ilias_' . $language_key . '.') && $entry !== $expected_file) { + $invalid_files[] = $entry; + break; + } + } + } + } + + return array_values(array_unique($invalid_files)); + } + + public function checkLanguage(string $lang_key): bool + { + foreach ($this->language_file_directory_manager->getAllDirectories() as $directory) { + $required = $directory instanceof \ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; + if (!$this->checkLanguageFile($lang_key, $directory, $required)) { + return false; + } + } + + return true; + } + + public function checkLocalLanguageFile(string $lang_key): bool + { + // getCustomizingDirectories() yields exactly one directory today + // (see LanguageFileDirectoryManager), but is typed as a generator of + // possibly many - so check only the first one explicitly, instead of + // a foreach that returns on its first iteration (which reads like an + // accidental early return, not an intentional "only the first one"). + $directories = iterator_to_array( + $this->language_file_directory_manager->getCustomizingDirectories(), + false + ); + + if ($directories === []) { + return false; + } + + return $this->checkLanguageFile($lang_key, $directories[0], true); + } + + /** + * Validates a single directory's lang-file for $lang_key: the file + * itself has a header and each entry consists of exactly three elements + * (module, identifier, value). If $required is false, a missing + * directory/file is treated as acceptable (valid); if true, it fails + * the check. + */ + private function checkLanguageFile( + string $lang_key, + \ILIAS\Language\ComponentTranslation\LanguageFileDirectory $directory, + bool $required + ): bool { + $working_dir = getcwd(); + $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + + try { + if (!is_dir($path)) { + return !$required; + } + chdir($path); + + if (!is_file($lang_file)) { + return !$required; + } + + $content = $this->cutHeader(file($lang_file)); + if ($content === false) { + return false; + } + + $prefix = $directory->getPrefix(); + + foreach ($content as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = explode(self::SEPARATOR, $line); + + if (!empty($prefix)) { + array_unshift($parts, $prefix); + } + if (count($parts) !== 3) { + return false; + } + if (!\ilStr::isUtf8($parts[2])) { + return false; + } + } + } finally { + chdir($working_dir); + } + + return true; + } +} diff --git a/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php b/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php new file mode 100644 index 000000000000..8eb7da7faa71 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstalledLanguageRepository.php @@ -0,0 +1,116 @@ + + */ + public function getInstalledLanguages(): array; + + /** + * Language keys installed as "installed_local" (in the database). + * + * @return list + */ + public function getInstalledLocalLanguages(): array; + + /** + * All languages registered in object_data, keyed by language key. + * + * @return array + */ + public function getAvailableLanguages(): array; + + /** + * Locally changed language entries for a language key, in a given + * change-date range. + * + * @return array> [module][identifier] => value + */ + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array; + + /** + * Every entry currently stored for a language key, regardless of + * local_change - unlike getLocalChanges(), which only returns entries + * with a non-null local_change in a given date range (i.e. previously + * recorded customizations), this also includes plain base/global data. + * Used to seed a re-seed of lng_data that only reads the customizing + * directory (see LanguageInstallationManager::insertLanguageForApplyingLocalChanges()), + * where nothing re-reads the base files to fill in what this doesn't + * provide. + * + * @return array> [module][identifier] => value + */ + public function getLanguageEntries(string $lang_key): array; + + /** + * Language keys for which a *.lang.local file exists. + * + * @return list + */ + public function getLocalLanguages(): array; + + /** + * Language keys for which an installable main *.lang file exists. + * + * @return list + */ + public function getInstallableLanguages(): array; + + /** + * Local language files whose names contain a requested language key but + * do not match the expected naming scheme. + * + * @param list $language_keys + * @return list + */ + public function getInvalidLocalLanguageFiles(array $language_keys): array; + + /** + * Validates the logical structure of all *.lang files (global, + * component and, if present, customizing) for a language key. + */ + public function checkLanguage(string $lang_key): bool; + + /** + * Validates only the customizing/local language file for a language + * key. Unlike checkLanguage(), a missing local file is NOT acceptable + * here - used when a caller explicitly wants to validate a local + * customization file, e.g. before activating "installed_local" status + * for an already globally-installed language. + */ + public function checkLocalLanguageFile(string $lang_key): bool; +} diff --git a/components/ILIAS/Language/src/Setup/LanguageFileParsing.php b/components/ILIAS/Language/src/Setup/LanguageFileParsing.php new file mode 100644 index 000000000000..b7ac303b3c54 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageFileParsing.php @@ -0,0 +1,53 @@ +getPath(), '/'); + } + + /** + * @param string[] $content expects an ILIAS lang-file + * @return bool|string[] the file's entries with the header stripped, or false if no header + * marker was found + */ + private function cutHeader(array $content) + { + foreach ($content as $key => $val) { + if (trim($val) === "") { + return array_slice($content, $key + 1); + } + } + return false; + } +} diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php new file mode 100644 index 000000000000..8bd71aac6401 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -0,0 +1,432 @@ +db instanceof \Closure ? ($this->db)() : $this->db; + } + + private function utcTimestamp(?int $unix_timestamp = null): string + { + if ($unix_timestamp !== null) { + return gmdate("Y-m-d H:i:s", $unix_timestamp); + } + $now = $this->now !== null ? ($this->now)() : new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + return $now->format('Y-m-d H:i:s'); + } + + /** + * Install the given languages, uninstall/flush all others that were + * previously known. Mirrors ilSetupLanguage::installLanguages(). + * + * @param list $lang_keys + * @param list $local_keys unused, kept for backwards compatibility - see + * ilSetupLanguage::installLanguages(), which never used it either; + * local languages are always looked up via the repository instead. + * @return list|bool list of language keys that failed validation, or true + */ + public function installLanguages(array $lang_keys, array $local_keys = []): array|bool + { + $ilDB = $this->db(); + + $err_lang = []; + $db_langs = $this->repository->getAvailableLanguages(); + $local_langs = $this->repository->getLocalLanguages(); + + foreach ($lang_keys as $lang_key) { + if ($this->repository->checkLanguage($lang_key)) { + $this->flushLanguage($lang_key, "keep_local"); + $this->insertLanguageForInstallation($lang_key); + + // register language first time install; an already-known + // language's status is (re-)synced below instead. + if (!array_key_exists($lang_key, $db_langs)) { + $this->registerInstalledLanguage($lang_key, $db_langs, $local_langs); + } + } else { + $err_lang[] = $lang_key; + } + } + + foreach ($db_langs as $key => $val) { + if (!in_array($key, $err_lang, true)) { + if (in_array($key, $lang_keys, true)) { + $this->registerInstalledLanguage($key, $db_langs, $local_langs); + } else { + $this->flushLanguage($key, "all"); + + if (strpos($val["status"], "installed") === 0) { + $query = "UPDATE object_data SET " . + "description = " . $ilDB->quote("not_installed", "text") . ", " . + "last_update = " . $ilDB->quote($this->utcTimestamp(), "timestamp") . " " . + "WHERE obj_id = " . $ilDB->quote($val["obj_id"], "integer") . " " . + "AND type = " . $ilDB->quote("lng", "text"); + $ilDB->manipulate($query); + } + } + } + } + + return ($err_lang) ?: true; + } + + /** + * Registers or updates the object_data bookkeeping row for a language + * that has just been flushed/(re-)inserted, deciding "installed" vs. + * "installed_local" and INSERTing a fresh row or UPDATEing the existing + * one accordingly. This is the single source of truth for that + * bookkeeping - both installLanguages() and + * \ILIAS\Language\Activities\InstallLanguage::perform() call this + * instead of duplicating the object_data INSERT/UPDATE. + * + * @param array $known_languages result of + * InstalledLanguageRepository::getAvailableLanguages() + * @param list $local_language_keys result of + * InstalledLanguageRepository::getLocalLanguages() + */ + public function registerInstalledLanguage( + string $lang_key, + array $known_languages, + array $local_language_keys + ): void { + $db = $this->db(); + $installation_type = in_array($lang_key, $local_language_keys, true) ? "installed_local" : "installed"; + + if (!array_key_exists($lang_key, $known_languages)) { + $language_id = $db->nextId("object_data"); + $query = "INSERT INTO object_data " . + "(obj_id,type,title,description,owner,create_date,last_update) " . + "VALUES " . + "(" . + $db->quote($language_id, "integer") . "," . + $db->quote("lng", "text") . "," . + $db->quote($lang_key, "text") . "," . + $db->quote($installation_type, "text") . "," . + $db->quote("-1", "integer") . "," . + $db->now() . "," . + $db->now() . + ")"; + $db->manipulate($query); + return; + } + + $query = "UPDATE object_data SET " . + "description = " . $db->quote($installation_type, "text") . ", " . + "last_update = " . $db->quote($this->utcTimestamp(), "timestamp") . " " . + "WHERE obj_id = " . $db->quote($known_languages[$lang_key]["obj_id"], "integer") . " " . + "AND type = " . $db->quote("lng", "text"); + $db->manipulate($query); + } + + public function flushLanguageForInstallation(string $lang_key): void + { + $this->flushLanguage($lang_key, "keep_local"); + } + + public function flushLanguageForUninstallation(string $lang_key): void + { + $this->flushLanguage($lang_key, "all"); + } + + /** + * @param string $mode either "all" or "keep_local" + */ + private function flushLanguage(string $lang_key, string $mode = "all"): void + { + $this->deleteLangData($lang_key, ($mode === "keep_local")); + + if ($mode === "all") { + $this->db()->manipulate("DELETE FROM lng_modules WHERE lang_key = " . + $this->db()->quote($lang_key, "text")); + } + } + + private function deleteLangData(string $lang_key, bool $keep_local_change): void + { + $ilDB = $this->db(); + + if (!$keep_local_change) { + $ilDB->manipulate("DELETE FROM lng_data WHERE lang_key = " . + $ilDB->quote($lang_key, "text")); + } else { + $ilDB->manipulate("DELETE FROM lng_data WHERE lang_key = " . + $ilDB->quote($lang_key, "text") . + " AND local_change IS NULL"); + } + } + + public function insertLanguageForInstallation(string $lang_key): void + { + // Every directory the LanguageFileDirectoryManager knows about, + // including the customizing/local one - so an existing custom + // language file is (re-)applied automatically - merged on top of + // whatever local changes are already recorded in the DB. + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getAllDirectories(), + $this->repository->getLocalChanges($lang_key) + ); + } + + /** + * Re-seed a language purely from the global/component language files, + * deliberately leaving out the customizing/local directory. + * + * This is the write path behind "remove local changes": that action + * flushes all data for the language and must reinstall a clean copy - + * if it went through insertLanguageForInstallation() instead, the + * customizing directory's file would immediately be re-applied (and + * re-marked with a fresh local_change timestamp), silently undoing the + * removal it was just asked to perform. + */ + public function insertLanguageForRemovingLocalChanges(string $lang_key): void + { + // Global/component directories only, and an empty seed - a clean + // slate with nothing to preserve or merge. + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getDirectories(), + [] + ); + } + + /** + * (Re-)apply only the customizing/local directory's file on top of an + * already installed language, without touching the base/global data at + * all - the opposite split from insertLanguageForRemovingLocalChanges(). + * + * This is the write path behind the "Install local" GUI command applied + * to an already-installed language (see ilObjLanguageFolderGUI and + * \ILIAS\Language\Activities\InstallLanguage::perform()): unlike + * insertLanguageForInstallation(), the caller does not flush anything + * first, and the base/global directories are never read here either - + * so there is no re-parsing of the (usually much larger) base language + * files, and no risk of the base data being touched. + * + * Because nothing here re-reads the base directories to fill in what the + * customizing file does not cover, the seed passed to insertLanguage() + * must already contain every entry currently stored for the language - + * both base data and any previously recorded local changes - not just + * the local changes the way insertLanguageForInstallation()'s seed does. + * insertLanguage() rebuilds the lng_modules cache row for every module + * it sees an entry for, from scratch, using exactly the seed plus + * whatever it reads from the given directories; a narrower seed (e.g. + * only previously local entries) would make any base entry not + * overridden by the customizing file silently disappear from that + * module's cache row. + */ + public function insertLanguageForApplyingLocalChanges(string $lang_key): void + { + $this->insertLanguage( + $lang_key, + $this->language_file_directory_manager->getCustomizingDirectories(), + $this->repository->getLanguageEntries($lang_key) + ); + } + + /** + * Writes whatever data the given $directories/$lang_array seed produce - it does not + * itself decide which directories to read or what to seed with; that is entirely up to + * the caller (see the three call sites above, each of which already states its intent in its + * own name). + * + * @param iterable $directories + * @param array> $lang_array module => identifier => value + */ + private function insertLanguage(string $lang_key, iterable $directories, array $lang_array): void + { + $ilDB = $this->db(); + $working_dir = getcwd(); + + // Every exit path below - including an exception thrown out of a DB + // call - must restore the working directory. This method chdir()s + // into each language directory in turn to read its file with a + // relative path; PHP-FPM/mod_php worker processes are long-lived and + // reused across unrelated requests, so a cwd left dangling here (e.g. + // inside lang/customizing/ after an error) would silently corrupt + // relative-path filesystem checks - such as this same class' + // checkLanguageFile() - for every later request handled by that + // worker, for any language, not just this one. This is why a + // language check can spuriously fail for a file that is perfectly + // valid on disk. + try { + $values_sql = []; + + foreach ($directories as $directory) { + $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); + $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + + if (!is_dir($path)) { + continue; + } + chdir($path); + + if (!is_file($lang_file)) { + continue; + } + + $content = $this->cutHeader(file($lang_file)); + if (!$content) { + continue; + } + + $prefix = $directory->getPrefix(); + $is_local = $directory->isLocal(); + + // Both of these depend only on the language and on this + // directory's file - never on the individual entry - so they + // are resolved once per directory. Doing it inside the loop + // below meant one "SELECT ... FROM lng_data" per line of the + // file: a customizing file with a few thousand entries issued + // a few thousand identical queries. + $change_date = null; + $newer_db_changes = []; + if ($is_local) { + // A local file overwrites, unless the database holds an + // even newer change for that entry. + $newer_db_changes = $this->repository->getLocalChanges( + $lang_key, + $this->utcTimestamp(filemtime($lang_file)) + ); + // One import timestamp for every entry of this file. + $change_date = $this->utcTimestamp(); + } + + foreach ($content as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $separated = explode(self::SEPARATOR, $line); + + if (!empty($prefix)) { + array_unshift($separated, $prefix); + } + + $pos = strpos($separated[2], self::COMMENT_SEPARATOR); + if ($pos !== false) { + $separated[3] = substr($separated[2], $pos + strlen(self::COMMENT_SEPARATOR)); + $separated[2] = substr($separated[2], 0, $pos); + } + + $module = $separated[0]; + $identifier = $separated[1]; + $value = $separated[2]; + + if (!$is_local) { + // Respect local changes already recorded in the + // database, and let an earlier directory win. + if (isset($lang_array[$module][$identifier])) { + continue; + } + } elseif (isset($newer_db_changes[$module][$identifier])) { + $lang_array[$module][$identifier] = $newer_db_changes[$module][$identifier]; + continue; + } + + $values_sql[] = sprintf( + "(%s,%s,%s,%s,%s,%s)", + $ilDB->quote($module, "text"), + $ilDB->quote($identifier, "text"), + $ilDB->quote($lang_key, "text"), + $ilDB->quote($value, "text"), + $ilDB->quote($change_date, "timestamp"), + $ilDB->quote($separated[3] ?? null, "text") + ); + + $lang_array[$module][$identifier] = $value; + } + } + + if ($values_sql !== []) { + $query = "INSERT INTO lng_data (module,identifier,lang_key,value,local_change,remarks) VALUES " + . implode(',', $values_sql) + . " ON DUPLICATE KEY UPDATE value=VALUES(value),remarks=VALUES(remarks),local_change=VALUES(local_change);"; + $ilDB->manipulate($query); + } + + if ($lang_array === []) { + return; + } + + $modules = array_keys($lang_array); + $inModulesToDelete = $ilDB->in('module', $modules, false, 'text'); + $ilDB->manipulate(sprintf( + "DELETE FROM lng_modules WHERE lang_key = %s AND $inModulesToDelete", + $ilDB->quote($lang_key, "text") + )); + + $modulesValuesSql = []; + foreach ($lang_array as $module => $lang_arr) { + $modulesValuesSql[] = sprintf( + "(%s,%s,%s)", + $ilDB->quote($module, "text"), + $ilDB->quote($lang_key, "text"), + $ilDB->quote(serialize($lang_arr), "clob") + ); + } + + $query = "INSERT INTO lng_modules (module, lang_key, lang_array) VALUES " + . implode(',', $modulesValuesSql) + . ";"; + $ilDB->manipulate($query); + } finally { + chdir($working_dir); + } + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php new file mode 100644 index 000000000000..066be564331f --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -0,0 +1,97 @@ +createDefaultActivity(); + + $this->assertSame($activity::class, (string) $activity->getName()); + } + + public function testGetTypeIsCommand(): void + { + $this->assertSame($this->expectedActivityType(), $this->createDefaultActivity()->getType()); + } + + public function testGetDescriptionReturnsANonEmptyMarkdownDocument(): void + { + $this->assertNotSame('', $this->createDefaultActivity()->getDescription()->getRawRepresentation()); + } + + /** + * Reflection-level pin: getInputDescription() must declare exactly Activity::getInputDescription()'s + * own parameter count/types/nullability/return type for every concrete Activity - no extra + * parameter, even an optional one. A parent declaring MORE parameters than a subclass + * overriding it with the plain interface signature is a PHP variance fatal error at + * class-load time (reproduced for AddLanguageEntry under PHP 8.5.4 - see + * AddLanguageEntryTest's own process-isolated regression test). + */ + public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfaceSignature(): void + { + $activity = $this->createDefaultActivity(); + + $interface_method = new ReflectionMethod(Activity::class, 'getInputDescription'); + $own_method = new ReflectionMethod($activity::class, 'getInputDescription'); + + $this->assertSame( + $interface_method->getNumberOfParameters(), + $own_method->getNumberOfParameters(), + 'getInputDescription() must declare exactly as many parameters as Activity::getInputDescription() - ' . + 'no extra (even optional) parameter, or a subclass overriding it with the plain interface ' . + 'signature triggers a PHP fatal error at class-load time.' + ); + $this->assertSame( + (string) $interface_method->getReturnType(), + (string) $own_method->getReturnType() + ); + + foreach ($interface_method->getParameters() as $i => $interface_parameter) { + $own_parameter = $own_method->getParameters()[$i]; + $this->assertSame((string) $interface_parameter->getType(), (string) $own_parameter->getType()); + $this->assertSame($interface_parameter->allowsNull(), $own_parameter->allowsNull()); + } + } +} diff --git a/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php new file mode 100644 index 000000000000..de1d7ed85ff6 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/ActivityWithPerformResultContractTestCase.php @@ -0,0 +1,57 @@ +createDefaultActivity(); + $result = $activity->perform($this->validPerformParameters()); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + $declared_field_names = []; + foreach ($description->getFields() as $field) { + $declared_field_names[] = $field->getName(); + } + + $this->assertSame($declared_field_names, array_keys($result)); + } +} diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php new file mode 100644 index 000000000000..e7d518c79adc --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -0,0 +1,1497 @@ +createActivity([]); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity([], rbac: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity([], rbac: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): void + { + $repository = new FakeInstalledLanguageRepository(static function (): array { + throw new \LogicException('getInstalledLanguages() must never be called by isAllowedToPerform()'); + }); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \LogicException('replace_lang_entry must never be called by isAllowedToPerform()'); + }; + $update_module_cache = static function (): void { + throw new \LogicException('update_module_cache must never be called by isAllowedToPerform()'); + }; + + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache + ); + + $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsEmptyOnes(): void + { + $calls = []; + $replace_lang_entry = static function ( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ) use (&$calls): bool { + $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks]; + return true; + }; + $update_module_cache = static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use (&$calls): void { + $calls['cache'][] = [$lang_key, $module, $identifier, $value]; + }; + $user_login = static fn(int $usr_id): string => 'login-' . $usr_id; + + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache, + user_login: $user_login + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + // 'fr' left out entirely - must be treated like a blank value. + ], + 'usr_id' => 6, + ]); + + $this->assertSame('common', $result['module']); + $this->assertSame('new_topic', $result['identifier']); + $this->assertSame(['de', 'en'], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + + $this->assertCount(2, $calls['replace']); + $this->assertSame(['common', 'new_topic', 'de', 'Hallo'], array_slice($calls['replace'][0], 0, 4)); + $this->assertSame('login-6', $calls['replace'][0][5]); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $calls['replace'][0][4]); + $this->assertSame(['common', 'new_topic', 'en', 'Hello'], array_slice($calls['replace'][1], 0, 4)); + + $this->assertCount(2, $calls['cache']); + $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); + $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); + + // Never touched for the skipped language. + foreach ($calls['replace'] as $call) { + $this->assertNotSame('fr', $call[2]); + } + foreach ($calls['cache'] as $call) { + $this->assertNotSame('fr', $call[0]); + } + } + + /** + * Regression test for the deliberate "not transactional" behaviour noted above perform()'s + * write loop: if replace_lang_entry() throws partway through (here: the 3rd of 3 languages), + * languages already written must remain written, and the exception must propagate rather than + * roll back. + */ + public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void + { + $calls = []; + $replace_lang_entry = static function ( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ) use (&$calls): bool { + $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks]; + if ($lang_key === 'fr') { + throw new \RuntimeException('simulated database error while writing "fr"'); + } + return true; + }; + + $activity = $this->createActivity( + ['de', 'en', 'fr'], + replace_lang_entry: $replace_lang_entry, + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => 'Hello', + 'fr' => 'Bonjour', + ], + 'usr_id' => 6, + ]); + $this->fail('Expected the simulated \RuntimeException to propagate out of perform().'); + } catch (\RuntimeException $e) { + $this->assertSame('simulated database error while writing "fr"', $e->getMessage()); + + // "de" and "en" - written before the failing "fr" - remain + // written: perform() gives no atomicity guarantee across the + // write loop (see perform()'s own "Not transactional..." + // comment above the loop). + $this->assertSame( + ['common', 'new_topic', 'de', 'Hallo', $calls['replace'][0][4], 'default-login'], + $calls['replace'][0] + ); + $this->assertSame( + ['common', 'new_topic', 'en', 'Hello', $calls['replace'][1][4], 'default-login'], + $calls['replace'][1] + ); + $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); + $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); + + // Exactly the 3 attempted calls - "fr" itself was attempted + // (and recorded before throwing) but never reached + // update_module_cache(). + $this->assertCount(3, $calls['replace']); + $this->assertCount(2, $calls['cache']); + } + } + + // ----------------------------------------------------------------- + // "de"/"en" mandatory, all-or-nothing (see getDescription()'s markdown) + // ----------------------------------------------------------------- + + public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNothingForAnyLanguage(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + // 'en' left blank - must reject the whole request. + ], + 'usr_id' => 6, + ]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + // Nothing written for either language - not even the valid "de". + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + // Same de/en rule as the perform() test above, exercised via maybePerformAs() with a real UI + // Factory. + public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + 'en' => ' ', // whitespace-only counts as blank too + ], + ]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testPerformRejectsWholeRequestAndWritesNothingWhenDeIsBlankWhileEnIsValid(): void + { + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $this->expectException(InvalidInputException::class); + try { + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['en' => 'Hello'], + 'usr_id' => 6, + ]); + } finally { + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + } + + public function testDeAndEnNotInstalledAtAllImposesNoMandatoryRequirement(): void + { + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + } + + public function testWhitespaceOnlyValueIsTreatedAsEmptyAndSkipped(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule tested elsewhere. + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['fr' => " \t "], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testValueSurroundedByWhitespaceIsTrimmedBeforeBeingWritten(): void + { + $calls = []; + $activity = $this->createActivity( + ['de'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => " Hallo Welt "], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + $this->assertSame('Hallo Welt', $calls['replace'][0][3]); + $this->assertSame('Hallo Welt', $calls['cache'][0][3]); + } + + /** + * A non-string translation value (e.g. from a direct perform() caller bypassing + * normalizeParameters()) must be treated as empty, not coerced or crash. + */ + public function testNonStringTranslationValueIsTreatedAsEmptyAndSkippedRatherThanCoerced(): void + { + // 'fr' avoids the separate 'de'/'en' mandatory-value rule tested elsewhere. + $calls = []; + $activity = $this->createActivity( + ['fr'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['fr' => 42], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame(['fr'], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + } + + public function testNoInstalledLanguagesResultsInEmptyBucketsAndNoWrites(): void + { + $calls = []; + $activity = $this->createActivity( + [], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame([], $result['added_language_keys']); + $this->assertSame([], $result['skipped_empty_language_keys']); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testUserLoginClosureIsCalledExactlyOnceWithTheGivenUsrIdRegardlessOfLanguageCount(): void + { + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'resolved-login'; + }; + + $activity = $this->createActivity(['de', 'en'], user_login: $user_login); + + $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => 99, + ]); + + $this->assertSame([99], $login_calls); + } + + // A missing usr_id is explicitly allowed (see perform()'s own docblock): a generic caller + // following the plain Activity contract never supplies one. + public function testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure(): void + { + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'must-not-be-used'; + }; + + $calls = []; + $activity = $this->createActivity( + ['de', 'en'], + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls), + user_login: $user_login + ); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + // 'usr_id' deliberately not present at all. + ]); + + $this->assertSame(['de', 'en'], $result['added_language_keys']); + $this->assertSame([], $login_calls, 'user_login() must never be called when usr_id is absent.'); + $this->assertSame('', $calls['replace'][0][5]); + $this->assertSame('', $calls['replace'][1][5]); + } + + public static function invalidPerformParametersProvider(): array + { + return [ + 'missing module' => [['identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'empty string module' => [['module' => '', 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'non-string module' => [['module' => 42, 'identifier' => 'foo', 'translations' => [], 'usr_id' => 6]], + 'missing identifier' => [['module' => 'common', 'translations' => [], 'usr_id' => 6]], + 'empty string identifier' => [['module' => 'common', 'identifier' => '', 'translations' => [], 'usr_id' => 6]], + 'non-string identifier' => [['module' => 'common', 'identifier' => [], 'translations' => [], 'usr_id' => 6]], + 'missing translations' => [['module' => 'common', 'identifier' => 'foo', 'usr_id' => 6]], + 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope', 'usr_id' => 6]], + // A missing/null usr_id is explicitly ALLOWED (see + // testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure() + // and testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations() below) - it must + // NOT appear here as an "invalid" case. Only a non-null usr_id of the wrong TYPE is invalid. + 'non-int usr_id' => [['module' => 'common', 'identifier' => 'foo', 'translations' => [], 'usr_id' => '6']], + ]; + } + + #[DataProvider('invalidPerformParametersProvider')] + public function testPerformRejectsInvalidOrIncompleteParameters(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform($parameters); + } + + /** + * A missing or null usr_id, given otherwise-valid translations, must succeed - not throw (see + * perform()'s own docblock: only a non-null usr_id of the wrong type is rejected). + */ + public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array + { + return [ + 'missing usr_id' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo', 'en' => 'Hello']], + ], + 'null usr_id' => [ + [ + 'module' => 'common', + 'identifier' => 'foo', + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + 'usr_id' => null, + ], + ], + ]; + } + + #[DataProvider('validUsrIdMissingOrNullWithValidTranslationsProvider')] + public function testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations(array $parameters): void + { + $activity = $this->createActivity(['de', 'en']); + + $result = $activity->perform($parameters); + + $this->assertSame('common', $result['module']); + $this->assertSame('foo', $result['identifier']); + $this->assertSame(['de', 'en'], $result['added_language_keys']); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(['de'])->perform('not-an-array'); + } + + // ----------------------------------------------------------------- + // Snapshot drift between getInputDescription() and perform() - see + // getInputDescription()'s own docblock. + // ----------------------------------------------------------------- + + /** + * The fake repository below returns different installed-language lists on its 1st + * (getInputDescription()) and 2nd (perform()) call, modeling this snapshot drift within one + * maybePerformAs() call. + */ + public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: getInputDescription() - 'fr' not installed yet, so no + // field/value for it exists in the form. + // 2nd call: perform() - 'fr' has since been installed. + return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr']; + }); + + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + // No 'fr' key at all - getInputDescription() (1st call) never + // asked for one, since it did not yet know 'fr' was installed. + 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame(['de', 'en'], $value['added_language_keys']); + $this->assertSame(['fr'], $value['skipped_empty_language_keys']); + // Two independent snapshots, not a cached one - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + // Fail-closed branch: "en" becomes mandatory only after getInputDescription() already built + // the form without asking for it - the whole request must be rejected, not partially skipped. + public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: getInputDescription() - only "de" installed, "en" not + // yet, so no field/value for "en" exists in the form. + // 2nd call: perform() - "en" has since been installed. + return $calls === 1 ? ['de'] : ['de', 'en']; + }); + + $calls_to_write_closures = []; + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures), + update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + // No 'en' key - getInputDescription() (1st call) never asked for + // one, since "en" was not yet installed when the form was built. + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + // Only "en" is reported missing - "de" has a valid value, so the mandatory-value check + // doesn't flag it too. + $this->assertSame('A value is required for: en.', $error->getMessage()); + // Nothing written for "de" either - fail-closed, not fail-partial. + $this->assertArrayNotHasKey('replace', $calls_to_write_closures); + $this->assertArrayNotHasKey('cache', $calls_to_write_closures); + // Two independent snapshots, not a cached one - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + /** + * The opposite direction from the two tests above: an OPTIONAL language uninstalled between + * the two calls has a submitted value that is neither written nor rejected - it is silently + * dropped, unlike installing "de"/"en" (fail-closed) or installing an optional language + * (skipped normally). "fr" isolates this from the separate "de"/"en" mandatory rule tested + * elsewhere. + */ + public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = 0; + $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { + $calls++; + // 1st call: getInputDescription() - "fr" still installed, so a + // field/value for it exists in the form. + // 2nd call: perform() - "fr" has since been uninstalled. + return $calls === 1 ? ['de', 'en', 'fr'] : ['de', 'en']; + }); + + $calls_to_write_closures = []; + $activity = $this->createActivity( + [], + rbac: $rbac, + installed_language_repository: $repository, + replace_lang_entry: $this->spyReplaceLangEntry($calls_to_write_closures), + update_module_cache: $this->spyUpdateModuleCache($calls_to_write_closures) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + // A valid value IS given for "fr" - getInputDescription() (1st + // call) still asked for it, since "fr" was installed at the time. + 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame(['de', 'en'], $value['added_language_keys']); + $this->assertSame([], $value['skipped_empty_language_keys']); + // "fr"'s given value is silently dropped: it appears in neither list. + $this->assertNotContains('fr', $value['added_language_keys']); + $this->assertNotContains('fr', $value['skipped_empty_language_keys']); + // The remaining, still-installed languages are written normally. + $this->assertArrayHasKey('replace', $calls_to_write_closures); + $written_lang_keys = array_column($calls_to_write_closures['replace'], 2); + $this->assertSame(['de', 'en'], $written_lang_keys); + $this->assertNotContains('fr', $written_lang_keys); + // Two independent snapshots, not a cached one - see getInputDescription()'s own docblock. + $this->assertSame(2, $calls); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndReturnsResultErrorWithNoPermMessage(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + // getInputDescription() (built for real via RealFieldsUiFactory) calls + // txt('meta_l_de') to label the 'de' translation field before + // isAllowedToPerform() is ever reached (grinding happens first) - the + // mock must tolerate that call too, not just 'msg_no_perm_write'. + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback( + static fn(string $key): string => $key === 'msg_no_perm_write' ? 'no write permission' : $key + ); + + $calls = []; + $activity = $this->createActivity( + ['de'], + language: $language, + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $this->assertSame('no write permission', $result->error()); + $this->assertArrayNotHasKey('replace', $calls); + } + + public function testPermissionGrantedPerformsAndReturnsOkResultWithExpectedStructure(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(['de', 'fr'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertFalse($result->isError()); + $value = $result->value(); + $this->assertSame('common', $value['module']); + $this->assertSame('new_topic', $value['identifier']); + $this->assertSame(['de'], $value['added_language_keys']); + $this->assertSame(['fr'], $value['skipped_empty_language_keys']); + } + + public function testUsrIdArgumentIsUsedForBothThePermissionCheckAndTheAuditLoginRegardlessOfRawParameters(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(true); + + $login_calls = []; + $user_login = static function (int $usr_id) use (&$login_calls): string { + $login_calls[] = $usr_id; + return 'login'; + }; + + $activity = $this->createActivity(['de'], rbac: $rbac, user_login: $user_login); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + // A caller-supplied usr_id must be ignored entirely. + 'usr_id' => 999, + ]); + + $this->assertFalse($result->isError()); + $this->assertSame([6], $login_calls); + } + + public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \RuntimeException('database write failed'); + }; + + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $replace_lang_entry + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('database write failed', $error->getMessage()); + } + + /** + * AddLanguageEntry inherits LanguageActivity::maybePerformAs()'s catch block unchanged; this + * exercises it with a real \TypeError (not an \Exception) from within perform() - `new + * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a + * \RuntimeException rather than crash maybePerformAs() itself. + */ + public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $replace_lang_entry = static function (): bool { + throw new \TypeError('simulated TypeError, not an \Exception'); + }; + + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $replace_lang_entry + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + /** + * Same inherited try/catch (LanguageActivity::maybePerformAs()) as above, but triggered via + * AddLanguageEntry's real getInputDescription() - here failing inside + * `$this->lng->txt('meta_l_' . $lang_key)`. The rbac system must never be reached, since the + * failure happens before grinding/permission-checking. + */ + public function testExceptionWhileBuildingTheInputDescriptionItselfIsTurnedIntoAResultErrorNotPropagated(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $language = $this->createMock(Language::class); + $language->method('txt')->willThrowException( + new \RuntimeException('simulated failure while building the translation field label') + ); + + $activity = $this->createActivity(['de'], rbac: $rbac, language: $language); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('simulated failure while building the translation field label', $error->getMessage()); + } + + // A missing 'module' fails the required Text field inside grind() itself, before + // normalizeParameters()/isAllowedToPerform() run - so rbac must never be touched. + public function testMissingModuleViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertStringContainsString('module:', $result->error()->getMessage()); + } + + // Unlike the top level (unknown keys tolerated - see GrindsFormInput), a nested group like + // 'translations' rejects an unknown key outright, before isAllowedToPerform() runs. + public function testMaybePerformAsRejectsAnUnknownLanguageKeyInsideTheNestedTranslationsGroup(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $calls = []; + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls), + update_module_cache: $this->spyUpdateModuleCache($calls) + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [ + 'de' => 'Hallo', + // Not an installed language, i.e. not a field of the + // 'translations' group - must be rejected outright. + 'xx' => 'whatever', + ], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringContainsString('Unknown key(s) for translations: xx', $error->getMessage()); + $this->assertArrayNotHasKey('replace', $calls); + $this->assertArrayNotHasKey('cache', $calls); + } + + public function testMissingRawParametersKeyIsAResultError(): void + { + $activity = $this->createActivity(['de']); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + /** + * Both rejected by grind() itself, before normalizeParameters()/isAllowedToPerform() run, but + * for different reasons: a non-array 'translations' fails collectRawValues()'s type guard on + * the nested group; a non-string key can never match any of the group's string-keyed fields. + * Either way: an InvalidInputException, not an InvalidArgumentException. + */ + public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array + { + return [ + 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope']], + 'translations with non-string key' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => [0 => 'Hallo']], + ], + ]; + } + + #[DataProvider('invalidRawParametersRejectedByGrindWithStringErrorProvider')] + public function testMaybePerformAsRejectsStructurallyInvalidTranslationsAtGrindLevelWithoutCheckingPermission( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersProvider(): array + { + return [ + // 'de' is given a valid, non-blank value here on purpose, so + // that grind() itself succeeds and the blank module/identifier + // is instead caught by normalizeParameters()'s + // toNonEmptyString() - an InvalidInputException, reached + // before isAllowedToPerform(). + 'blank module' => [['module' => ' ', 'identifier' => 'foo', 'translations' => ['de' => 'Hallo']]], + 'blank identifier' => [['module' => 'common', 'identifier' => ' ', 'translations' => ['de' => 'Hallo']]], + // Outside the Text field's own type check - grind() itself + // converts the UI framework's own blank \InvalidArgumentException + // ("Display value does not match input type.") into an + // InvalidInputException (see grind()'s try/catch). + 'translations with non-string value' => [ + ['module' => 'common', 'identifier' => 'foo', 'translations' => ['de' => 42]], + ], + ]; + } + + #[DataProvider('invalidRawParametersProvider')] + public function testMaybePerformAsRejectsInvalidRawParametersAsResultError(array $raw_parameters): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = $this->createActivity(['de'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslationsAsGiven(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $calls = []; + $activity = $this->createActivity( + ['de'], + rbac: $rbac, + replace_lang_entry: $this->spyReplaceLangEntry($calls) + ); + + $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => ' common ', + 'identifier' => ' new_topic ', + 'translations' => ['de' => 'Hallo'], + ]); + + $this->assertSame('common', $calls['replace'][0][0]); + $this->assertSame('new_topic', $calls['replace'][0][1]); + } + + /** + * An empty translations array is a legal input (e.g. a caller merely + * reserving the module/identifier pair without giving any value yet) - + * every installed language is then reported skipped, not rejected. + */ + public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLanguage(): void + { + // 'fr'/'it' avoid the separate 'de'/'en' mandatory-value rule tested elsewhere. + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(['fr', 'it'], rbac: $rbac); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => [], + ]); + + $this->assertFalse($result->isError()); + $this->assertSame([], $result->value()['added_language_keys']); + $this->assertSame(['fr', 'it'], $result->value()['skipped_empty_language_keys']); + } + + // ----------------------------------------------------------------- + // getInputDescription() + // ----------------------------------------------------------------- + + /** + * End-to-end companion to ActivityContractTestCase's reflection-based signature pin: actually + * defines a subclass overriding getInputDescription() with the plain interface signature, + * reproducing the PHP fatal error the old, buggy signature triggered (verified under PHP + * 8.5.4). A PHP variance fatal is a compile-time error, not a catchable \Throwable, hence + * #[RunInSeparateProcess] - so a regression fails this one test instead of crashing the suite. + */ + #[RunInSeparateProcess] + public function testSubclassOverridingGetInputDescriptionWithThePlainInterfaceSignatureDoesNotTriggerAFatalError(): void + { + // txt() must be stubbed (getInputDescription() calls it per translation field) - a bare + // mock would trigger PHPUnit's "no expectations configured" notice. createActivity() can't + // be reused here since the point of this test is the inline `extends AddLanguageEntry` + // class declaration. + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback(static fn(string $k): string => $k); + + $activity = new class ( + refinery: $this->createMock(RefineryFactory::class), + language: $language, + rbac_system: $this->createMock(\ilRbacSystem::class), + installed_language_repository: new FakeInstalledLanguageRepository(static fn(): array => ['de']), + db: static fn(): \ilDBInterface => throw new \LogicException('db must not be resolved by this test'), + ) extends AddLanguageEntry { + // Declaring this override (the plain interface signature) is the point of the test - + // the old, buggy signature with an extra optional parameter would already fail to + // compile here. + public function getInputDescription(FieldFactory $f): FormInput + { + return parent::getInputDescription($f); + } + }; + + $this->assertInstanceOf( + FormInput::class, + $activity->getInputDescription($this->createRealFieldsUiFactory()->input()->field()) + ); + } + + public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslationFields(): void + { + $module_text = $this->createMock(Text::class); + $module_text->method('withRequired')->with(true)->willReturnSelf(); + $module_text->method('withDedicatedName')->with('module')->willReturnSelf(); + + $identifier_text = $this->createMock(Text::class); + $identifier_text->method('withRequired')->with(true)->willReturnSelf(); + $identifier_text->method('withDedicatedName')->with('identifier')->willReturnSelf(); + + $de_text = $this->createMock(Text::class); + $de_required = null; + $de_text->method('withRequired')->willReturnCallback(function (bool $required) use ($de_text, &$de_required) { + $de_required = $required; + return $de_text; + }); + $de_text->method('withDedicatedName')->with('de')->willReturnSelf(); + + $fr_text = $this->createMock(Text::class); + $fr_required = null; + $fr_text->method('withRequired')->willReturnCallback(function (bool $required) use ($fr_text, &$fr_required) { + $fr_required = $required; + return $fr_text; + }); + $fr_text->method('withDedicatedName')->with('fr')->willReturnSelf(); + + $text_calls = []; + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->method('text')->willReturnCallback( + function (string $label, ?string $byline = null) use (&$text_calls, $module_text, $identifier_text, $de_text, $fr_text) { + $text_calls[] = $label; + return match ($label) { + 'Module' => $module_text, + 'Identifier' => $identifier_text, + 'meta_l_de' => $de_text, + 'meta_l_fr' => $fr_text, + default => throw new \LogicException("unexpected text() label: $label"), + }; + } + ); + + $translations_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); + $translations_group->method('withDedicatedName')->with('translations')->willReturnSelf(); + + $outer_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); + + $group_calls = []; + $field->method('group')->willReturnCallback( + function (array $fields, ?string $label = null, ?string $byline = null) use ( + &$group_calls, + $de_text, + $fr_text, + $module_text, + $identifier_text, + $translations_group, + $outer_group + ) { + $group_calls[] = $fields; + if ($fields === ['de' => $de_text, 'fr' => $fr_text]) { + return $translations_group; + } + if ($fields === ['module' => $module_text, 'identifier' => $identifier_text, 'translations' => $translations_group]) { + return $outer_group; + } + throw new \LogicException('unexpected group() call'); + } + ); + + $language = $this->createMock(Language::class); + $language->method('txt')->willReturnCallback(static fn(string $key): string => $key); + + $activity = $this->createActivity( + ['de', 'fr'], + language: $language + ); + + $this->assertSame($outer_group, $activity->getInputDescription($field)); + $this->assertSame(['Module', 'Identifier', 'meta_l_de', 'meta_l_fr'], $text_calls); + // Only 'de' (and 'en', not present here) is required among translations. + $this->assertTrue($de_required); + $this->assertFalse($fr_required); + } + + // ----------------------------------------------------------------- + // getOutputDescription() + // ----------------------------------------------------------------- + + public function testOutputDescriptionDeclaresTheExpectedFourFields(): void + { + $string_group = $this->createMock(StringGroup::class); + $string_group->method('markdown')->willReturn( + $this->createMock(MarkdownFormattingToHTML::class) + ); + $refinery = $this->createMock(RefineryFactory::class); + $refinery->method('string')->willReturn($string_group); + + $activity = $this->createActivity(['de'], refinery: $refinery); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + + $field_names = []; + foreach ($description->getFields() as $field) { + $field_names[] = $field->getName(); + } + + $this->assertSame( + ['module', 'identifier', 'added_language_keys', 'skipped_empty_language_keys'], + $field_names + ); + } + + // ----------------------------------------------------------------- + // $db / default update_module_cache() guard clauses + // + // Every other test overrides update_module_cache with a no-op/spy, so the constructor's own + // $db-backed default (querying lng_modules) is otherwise never exercised. These tests inject a + // real \ilDBInterface mock to pin down the is_string() guard around unserialize(). Only "de" + // is installed, just enough to reach update_module_cache() once without ever reaching the real + // \ilObjLanguage::replaceLangModule() write. + // ----------------------------------------------------------------- + + private function createActivityWithRealUpdateModuleCacheDefault(\ilDBInterface $db): AddLanguageEntry + { + return new AddLanguageEntry( + refinery: $this->createMock(RefineryFactory::class), + language: $this->createMock(Language::class), + rbac_system: $this->createMock(\ilRbacSystem::class), + installed_language_repository: new FakeInstalledLanguageRepository(static fn(): array => ['de']), + language_folder_ref_id: 0, + replace_lang_entry: static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => true, + // $update_module_cache deliberately left null - the whole point + // of these tests is exercising the constructor's own default. + update_module_cache: null, + user_login: static fn(int $usr_id): string => 'default-login', + db: $db, + ); + } + + private function mockDbFetchingRow(?array $row): \ilDBInterface + { + $statement = $this->createMock(\ilDBStatement::class); + + $db = $this->createMock(\ilDBInterface::class); + $db->method('quote')->willReturnCallback( + static fn(mixed $value, string $type): string => "'" . (string) $value . "'" + ); + $db->method('query')->willReturn($statement); + $db->method('fetchAssoc')->with($statement)->willReturn($row); + + return $db; + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenNoLngModulesRowIsFound(): void + { + $db = $this->mockDbFetchingRow(null); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + // A missing row is normal and silently ignored - no exception must surface. + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnIsNull(): void + { + // Without the is_string() guard, unserialize(null, ...) throws a \TypeError under + // strict_types - this pins down the guard that prevents that. + $db = $this->mockDbFetchingRow(['lang_array' => null]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnIsNotAString(): void + { + $db = $this->mockDbFetchingRow(['lang_array' => 42]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + public function testDefaultUpdateModuleCacheDoesNothingWhenLangArrayColumnDeserializesToANonArray(): void + { + // A syntactically valid serialized string that decodes to something + // other than an array (e.g. a plain scalar) leaves nothing to merge + // the new entry into - silently do nothing, same as a missing row. + $db = $this->mockDbFetchingRow(['lang_array' => serialize('not-an-array')]); + + $activity = $this->createActivityWithRealUpdateModuleCacheDefault($db); + + $result = $activity->perform([ + 'module' => 'common', + 'identifier' => 'new_topic', + 'translations' => ['de' => 'Hallo'], + 'usr_id' => 6, + ]); + + $this->assertSame(['de'], $result['added_language_keys']); + } + + // ----------------------------------------------------------------- + // Test helpers + // ----------------------------------------------------------------- + + /** + * @param array $calls + */ + private function spyReplaceLangEntry(array &$calls): \Closure + { + return static function ( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ) use (&$calls): bool { + $calls['replace'][] = [$module, $identifier, $lang_key, $value, $local_change, $remarks]; + return true; + }; + } + + /** + * @param array $calls + */ + private function spyUpdateModuleCache(array &$calls): \Closure + { + return static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ) use (&$calls): void { + $calls['cache'][] = [$lang_key, $module, $identifier, $value]; + }; + } + + /** + * @param list $installed_languages + */ + private function createActivity( + array $installed_languages, + ?\ilRbacSystem $rbac = null, + ?Language $language = null, + ?InstalledLanguageRepository $installed_language_repository = null, + \ilDBInterface|\Closure|null $db = null, + int $language_folder_ref_id = 0, + ?\Closure $replace_lang_entry = null, + ?\Closure $update_module_cache = null, + ?\Closure $user_login = null, + ?RefineryFactory $refinery = null, + ): AddLanguageEntry { + // Safe no-op defaults (never the real \ilObjLanguage-backed closures, which need global + // $DIC). user_login() runs unconditionally in perform(), so it always needs one too. + // Tests that must prove a closure is never called pass their own throwing closure instead. + $replace_lang_entry ??= static fn( + string $module, + string $identifier, + string $lang_key, + string $value, + string $local_change, + string $remarks + ): bool => true; + $update_module_cache ??= static function ( + string $lang_key, + string $module, + string $identifier, + string $value + ): void { + }; + $user_login ??= static fn(int $usr_id): string => 'default-login'; + // $db defaults to a throwing stub: every test above overrides update_module_cache, so the + // constructor's real $db-based default should never be reached - a test that accidentally + // relies on it fails loudly instead of silently passing. + $db ??= static fn(): \ilDBInterface => throw new \LogicException( + 'db must not be resolved when update_module_cache is overridden' + ); + + return new AddLanguageEntry( + refinery: $refinery ?? $this->createMock(RefineryFactory::class), + language: $language ?? $this->createMock(Language::class), + rbac_system: $rbac ?? $this->createMock(\ilRbacSystem::class), + installed_language_repository: $installed_language_repository ?? new FakeInstalledLanguageRepository( + static fn(): array => $installed_languages + ), + language_folder_ref_id: $language_folder_ref_id, + replace_lang_entry: $replace_lang_entry, + update_module_cache: $update_module_cache, + user_login: $user_login, + db: $db, + ); + } +} + +/** + * Minimal in-memory stand-in for InstalledLanguageRepository: + * InstalledLanguageDatabaseRepository (the real implementation) needs an + * actual database connection, which unit tests must never construct. Only + * getInstalledLanguages() is exercised by AddLanguageEntry; every other + * method is unreachable from it and simply throws if a test ever calls it by + * mistake. + */ +final class FakeInstalledLanguageRepository implements InstalledLanguageRepository +{ + public function __construct( + private readonly \Closure $installed_languages, + ) { + } + + public function getInstalledLanguages(): array + { + return ($this->installed_languages)(); + } + + public function getInstalledLocalLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getAvailableLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLocalChanges(string $lang_key, string $min_date = "", string $max_date = ""): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLanguageEntries(string $lang_key): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getLocalLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getInstallableLanguages(): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function getInvalidLocalLanguageFiles(array $language_keys): array + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function checkLanguage(string $lang_key): bool + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } + + public function checkLocalLanguageFile(string $lang_key): bool + { + throw new \LogicException(__METHOD__ . ' is not used by AddLanguageEntry.'); + } +} diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php new file mode 100644 index 000000000000..3152db8ca176 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -0,0 +1,300 @@ +createMock(FormInput::class); + + $result = $this->host()->callGrind($description, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\LogicException::class, $result->error()); + } + + public static function checkboxWhitelistAcceptsRawValueProvider(): array + { + return [ + 'bool true' => [true, true], + 'int 1' => [1, true], + 'string "1"' => ['1', true], + 'string "true"' => ['true', true], + 'string "checked"' => ['checked', true], + 'string "on"' => ['on', true], + 'bool false' => [false, false], + 'int 0' => [0, false], + 'string "0"' => ['0', false], + 'string "false"' => ['false', false], + 'empty string' => ['', false], + 'null (field omitted entirely)' => [null, false], + ]; + } + + // Uses a REAL Checkbox field (not a mock) to also cover 'checked'/'on', the two raw values an + // actual HTML checkbox submits. + #[DataProvider('checkboxWhitelistAcceptsRawValueProvider')] + public function testGrindNormalizesEveryWhitelistedCheckboxRawValue(mixed $raw_value, bool $expected): void + { + $description = $this->checkboxGroupDescription(); + + $raw_parameters = $raw_value === null ? [] : ['enabled' => $raw_value]; + + $result = $this->host()->callGrind($description, $raw_parameters); + + $this->assertTrue($result->isOk()); + $this->assertSame($expected, $result->value()['enabled']); + } + + /** + * Every field this trait grinds is nested inside a top-level Group in real Activities - + * collectRawValues() only narrows $raw_value to one key when recursing into a Group's + * children, so a bare top-level field would wrongly receive the whole $raw_parameters array + * as its own value. + */ + private function checkboxGroupDescription(): FormInput + { + $checkbox = $this->createRealFieldsUiFactory()->input()->field()->checkbox('Enabled', '') + ->withDedicatedName('enabled'); + + return $this->createRealFieldsUiFactory()->input()->field()->group(['enabled' => $checkbox]); + } + + public static function checkboxWhitelistRejectsRawValueProvider(): array + { + return [ + 'array' => [[true]], + 'float' => [1.0], + 'unrecognized string' => ['maybe'], + ]; + } + + /** + * Anything outside the explicit whitelist must be rejected, not guessed at - grind()'s + * dedicated catch(\InvalidArgumentException) (before the generic catch(\Throwable)) must turn + * this into an InvalidInputException (a SafeToDisplayActivityError), which + * RendersActivityErrors relies on to show the rejection to the end user directly rather than + * logging it as an internal failure. + */ + #[DataProvider('checkboxWhitelistRejectsRawValueProvider')] + public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_value): void + { + $result = $this->host()->callGrind($this->checkboxGroupDescription(), ['enabled' => $raw_value]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + } + + /** + * Same \InvalidArgumentException -> InvalidInputException conversion, but reached via the UI + * framework's own Text::isClientSideValueOk() type check instead of + * normalizeCheckboxRawValue(). + */ + public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStringTextValueIntoInvalidInputException(): void + { + $text = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') + ->withDedicatedName('language_key'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'language_key' => $text, + ]); + + // A bool is neither a string nor an array - collectRawValues() + // passes it straight through unmodified (only array/Checkbox raw + // values are special-cased), so it reaches Text::withValue() as-is. + $result = $this->host()->callGrind($description, ['language_key' => true]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + } + + /** + * A validation failure must produce ": " (via describeInputError()/ + * collectFieldErrors()), identifying the field by its dedicated name - not the generic, + * uninformative "ui_error_in_group" text Group::withInput() would otherwise surface. + */ + public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidationFailure(): void + { + $language_key = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') + ->withRequired(true) + ->withDedicatedName('language_key'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'language_key' => $language_key, + ]); + + // Blank/missing raw value for a required field - fails its own + // hasMinLength(1) constraint inside grind() itself. + $result = $this->host()->callGrind($description, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringStartsWith('language_key: ', $error->getMessage()); + $this->assertNotSame('language_key: ', $error->getMessage()); + } + + /** + * A raw array-of-strings value (e.g. the shape class.ilObjLanguageFolderGUI.php sends for + * 'language_keys') must be joined into the same comma-separated string a real HTML text input + * would carry, before it reaches the field. + */ + public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSeparatedString(): void + { + $language_keys = $this->createRealFieldsUiFactory()->input()->field()->text('Language keys', '') + ->withDedicatedName('language_keys'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'language_keys' => $language_keys, + ]); + + $result = $this->host()->callGrind($description, ['language_keys' => ['de', 'fr']]); + + $this->assertTrue($result->isOk()); + $this->assertSame('de,fr', $result->value()['language_keys']); + } + + /** + * Regression test for the outermost group's $enforce_known_keys=false default (see + * collectRawValues()): an unrelated key elsewhere in top-level $raw_parameters must be + * ignored, not rejected. + */ + public function testGrindSilentlyIgnoresAnUnknownKeyAtTheTopLevelOfRawParameters(): void + { + $name = $this->createRealFieldsUiFactory()->input()->field()->text('Name', '') + ->withDedicatedName('name'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'name' => $name, + ]); + + $result = $this->host()->callGrind($description, [ + 'name' => 'Foo', + // Not a field of the outermost group - must be tolerated, not rejected. + 'some_unrelated_form_field' => 'bar', + ]); + + $this->assertTrue($result->isOk()); + $this->assertSame('Foo', $result->value()['name']); + } + + /** + * The unknown-key message must embed the key RAW/unescaped - escaping is + * RendersActivityErrors::activityErrorMessage()'s job, the single rendering seam; escaping + * here too would double-escape it. + */ + public function testGrindReportsUnknownNestedGroupKeysRawAndUnescaped(): void + { + $de = $this->createRealFieldsUiFactory()->input()->field()->text('German', '') + ->withDedicatedName('de'); + $translations = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'de' => $de, + ])->withDedicatedName('translations'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'translations' => $translations, + ]); + + $dangerous_unknown_key = ''; + + $result = $this->host()->callGrind($description, [ + 'translations' => [ + 'de' => 'Hallo', + $dangerous_unknown_key => 'whatever', + ], + ]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringContainsString('Unknown key(s) for translations: ' . $dangerous_unknown_key, $error->getMessage()); + // Not escaped here - htmlspecialchars() would turn '<'/'>' into + // '<'/'>', which must NOT happen at this layer. + $this->assertStringContainsString('', $error->getMessage()); + } + + public static function nonArrayNonNullGroupRawValueProvider(): array + { + return [ + 'string' => ['not-an-array'], + 'int' => [42], + 'bool' => [true], + ]; + } + + /** + * A group's raw value that is neither null (omitted) nor an array is a genuine type mismatch + * and must be rejected - silently degrading it to an empty array would instead make every + * child field look merely "omitted", masking the real problem behind a misleading + * required-field error. + */ + #[DataProvider('nonArrayNonNullGroupRawValueProvider')] + public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray( + mixed $raw_value + ): void { + $de = $this->createRealFieldsUiFactory()->input()->field()->text('German', '') + ->withDedicatedName('de'); + $translations = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'de' => $de, + ])->withDedicatedName('translations'); + $description = $this->createRealFieldsUiFactory()->input()->field()->group([ + 'translations' => $translations, + ]); + + $result = $this->host()->callGrind($description, ['translations' => $raw_value]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(InvalidInputException::class, $error); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertStringContainsString( + 'Expected an array of values (or none at all) for translations, got: ' . get_debug_type($raw_value), + $error->getMessage() + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php new file mode 100644 index 000000000000..7bdd3dc7ca12 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php @@ -0,0 +1,40 @@ +grind($description, $raw_parameters); + } +} diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php new file mode 100644 index 000000000000..26fdb895fe18 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -0,0 +1,705 @@ +createSetupLanguageMock(['de'], [], []); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + + return $this->createActivity($setup_language); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]; + } + + public function testSingleNewLanguageIsInstalled(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ' de ', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUnderInstallMode(): void + { + // Mode "install": 'de' is already installed and must be left + // completely alone (no validation, no write at all) - only the + // not-yet-installed 'fr' is actually installed. + $setup_language = $this->createSetupLanguageMock( + [ + 'de' => ['obj_id' => 1, 'status' => 'installed'], + ], + [], + ['de'] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('fr') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('fr'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('fr'); + + $result = $this->createActivity($setup_language)->perform([ + // Duplicate 'de' across the comma-separated string and the array + // must still be deduplicated exactly like before. + 'language_keys' => [' de, fr ', 'de'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['fr'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile(): void + { + // Even a language that was not installed before must be classified + // by "has a customizing/local file", not "was newly installed" - a + // fresh language that already ships a local file is still more + // accurately described as "installed with custom file" than as a + // plain install. + $setup_language = $this->createSetupLanguageMock( + [], + ['de'], + [] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame(['de'], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * This is the central bugfix regression test: mode "install" used to + * unconditionally (re-)install and (re-)apply the customizing/local file + * of an already installed language on every call. It must now be a + * complete no-op instead - not even checkLanguageForInstallation may run + * for it, since (re-)applying a customizing file is exclusively mode + * "install_local"'s job now. + */ + public function testInstallModeIsNoOpForAlreadyInstalledLanguageEvenWithACustomFile(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'], + ['de'] + ); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'], + ['de'] + ); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->once()) + ->method('insertLanguageForApplyingLocalChanges') + ->with('de'); + // The object_data bookkeeping itself is delegated to + // ilSetupLanguage::registerInstalledLanguage() (shared with + // installLanguages()) - see ilSetupLanguageTest for coverage of the + // actual INSERT/UPDATE SQL it builds. + $setup_language->expects($this->once()) + ->method('registerInstalledLanguage') + ->with( + 'de', + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['de'] + ); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame(['de'], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + /** + * "install_local" still runs its write methods for an already installed + * language even when no customizing/local file actually exists for it + * (there is simply nothing for them to write) - but the *result* must + * report this as "already installed", not as "installed with local + * file", since nothing observable actually changed. + */ + public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileIsReportedAsAlreadyInstalled(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + [], // no customizing/local file exists for 'de' + ['de'] + ); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->once()) + ->method('insertLanguageForApplyingLocalChanges') + ->with('de'); + $setup_language->expects($this->once())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * "install_local" on a language that is not installed at all must be a + * complete no-op - there is nothing installed yet to apply local + * changes on top of. + */ + public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + } + + /** + * When every requested language turns out to be a no-op (given the + * chosen mode and each language's install status), perform() must not + * even read the available/local languages or scan for invalid local + * files - there is nothing left to do that would need them. + */ + public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEntirely(): void + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturn(['de', 'en']); + $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); + $setup_language->expects($this->never())->method('getLocalLanguages'); + $setup_language->expects($this->never())->method('getInvalidLocalLanguageFiles'); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame(['de', 'en'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and only the ones actually affected + * may trigger any write method - under mode "install". + */ + public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBucket(): void + { + $setup_language = $this->createSetupLanguageMock( + ['de' => ['obj_id' => 1, 'status' => 'installed']], + ['fr'], // only 'fr' has a pending customizing/local file + ['de'] + ); + $setup_language->expects($this->exactly(2)) + ->method('checkLanguageForInstallation') + ->willReturnMap([ + ['en', true], + ['fr', true], + ]); + $setup_language->expects($this->exactly(2))->method('flushLanguageForInstallation'); + $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForApplyingLocalChanges'); + $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); + $setup_language->expects($this->once()) + ->method('getInvalidLocalLanguageFiles') + // Only the two languages actually being (fully) installed are + // relevant - the already-installed 'de' is untouched. + ->with(['en', 'fr']); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en', 'fr'], + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['en'], $result['installed_language_keys']); + $this->assertSame(['fr'], $result['installed_with_local_language_keys']); + $this->assertSame(['de'], $result['already_installed_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * The same idea, but under mode "install_local": no key may ever trigger + * checkLanguageForInstallation/flushLanguageForInstallation/ + * insertLanguageForInstallation - only insertLanguageForApplyingLocalChanges, + * and only for the already-installed keys. + */ + public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorrectBucket(): void + { + $setup_language = $this->createSetupLanguageMock( + [ + 'de' => ['obj_id' => 1, 'status' => 'installed'], + 'en' => ['obj_id' => 2, 'status' => 'installed'], + ], + ['de'], // only 'de' has a pending customizing/local file + ['de', 'en'] + ); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->exactly(2)) + ->method('insertLanguageForApplyingLocalChanges') + ->with($this->logicalOr('de', 'en')); + $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); + $setup_language->expects($this->once()) + ->method('getInvalidLocalLanguageFiles') + // Only the two already-installed languages are affected - the + // not-installed 'fr' is left untouched. + ->with(['de', 'en']); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en', 'fr'], + 'mode' => InstallLanguage::MODE_INSTALL_LOCAL, + ]); + + $this->assertSame([], $result['installed_language_keys']); + $this->assertSame(['de'], $result['installed_with_local_language_keys']); + $this->assertSame(['en'], $result['already_installed_language_keys']); + $this->assertSame(['fr'], $result['not_installed_language_keys']); + } + + public function testInstallingNewLanguageDoesNotUpdateAlreadyInstalledLanguages(): void + { + $setup_language = $this->createSetupLanguageMock( + [ + 'en' => ['obj_id' => 1, 'status' => 'installed'], + ], + [], + ['en'] + ); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation'); + $setup_language->expects($this->once()) + ->method('registerInstalledLanguage') + ->with( + 'de', + ['en' => ['obj_id' => 1, 'status' => 'installed']], + [] + ); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de'], $result['installed_language_keys']); + $this->assertSame([], $result['installed_with_local_language_keys']); + $this->assertSame([], $result['already_installed_language_keys']); + $this->assertSame([], $result['invalid_local_language_files']); + } + + public function testMalformedLocalLanguageFileDoesNotPreventStandardInstallation(): void + { + $setup_language = $this->createSetupLanguageMock([], [], [], ['ilias_de.lang.locl']); + $setup_language->expects($this->once()) + ->method('getInvalidLocalLanguageFiles') + ->with(['de']) + ->willReturn(['ilias_de.lang.locl']); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + + $this->assertSame(['de'], $result['installed_language_keys']); + $this->assertSame(['ilias_de.lang.locl'], $result['invalid_local_language_files']); + } + + public function testInvalidLanguageFileIsReportedAsActivityError(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('xx') + ->willReturn(false); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + + $this->expectException(\RuntimeException::class); + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'xx', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + } + + public function testMixedValidAndInvalidLanguagesDoNotMutate(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->exactly(2)) + ->method('checkLanguageForInstallation') + ->willReturnMap([ + ['de', true], + ['xx', false], + ]); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + + $this->expectException(\RuntimeException::class); + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de,xx', + 'mode' => InstallLanguage::MODE_INSTALL, + ]); + } + + public function testMissingModeParameterIsRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform([ + 'language_keys' => 'de', + ]); + } + + public function testInvalidModeValueIsRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform([ + 'language_keys' => 'de', + 'mode' => 'foo', + ]); + } + + public function testInputDescriptionUsesNamedLanguageKeysAndModeFields(): void + { + $text = $this->createMock(Text::class); + $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $text->expects($this->once()) + ->method('withDedicatedName') + ->with('language_keys') + ->willReturnSelf(); + + $select = $this->createMock(Select::class); + $select->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $select->expects($this->once()) + ->method('withDedicatedName') + ->with('mode') + ->willReturnSelf(); + + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->expects($this->once())->method('text')->with( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->willReturn($text); + $field->expects($this->once())->method('select')->with( + 'Mode', + [ + InstallLanguage::MODE_INSTALL => 'Install', + InstallLanguage::MODE_INSTALL_LOCAL => 'Install local', + ], + self::callback(static fn(mixed $value): bool => is_string($value)) + )->willReturn($select); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text, 'mode' => $select]) + ->willReturn($group); + + $activity = $this->createActivity( + $this->createSetupLanguageMock([], [], []) + ); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + public static function invalidLanguageKeysProvider(): array + { + return [ + 'empty (only whitespace/commas)' => [ + ['language_keys' => ' , ', 'mode' => InstallLanguage::MODE_INSTALL], + ], + 'nested array value' => [ + ['language_keys' => ['de', ['fr']], 'mode' => InstallLanguage::MODE_INSTALL], + ], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + 'three letters' => [ + ['language_keys' => 'deu', 'mode' => InstallLanguage::MODE_INSTALL], + ], + 'one letter' => [ + ['language_keys' => 'd', 'mode' => InstallLanguage::MODE_INSTALL], + ], + 'uppercase' => [ + ['language_keys' => 'DE', 'mode' => InstallLanguage::MODE_INSTALL], + ], + 'contains a digit' => [ + ['language_keys' => 'de1', 'mode' => InstallLanguage::MODE_INSTALL], + ], + 'contains a hyphen' => [ + ['language_keys' => 'de-at', 'mode' => InstallLanguage::MODE_INSTALL], + ], + ]; + } + + #[DataProvider('invalidLanguageKeysProvider')] + public function testInvalidLanguageKeysAreRejected(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see InstallLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends. + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndInstallsEachOne(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->expects($this->exactly(2))->method('flushLanguageForInstallation'); + $setup_language->expects($this->exactly(2))->method('insertLanguageForInstallation'); + + $result = $this->createActivity($setup_language, $rbac)->maybePerformAs( + $this->createRealFieldsUiFactory()->input(), + 6, + ['language_keys' => ['de', 'fr'], 'mode' => InstallLanguage::MODE_INSTALL] + ); + + $this->assertTrue($result->isOk()); + $this->assertSame(['de', 'fr'], $result->value()['installed_language_keys']); + } + + public function testPermissionDeniedBeforePerform(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with( + 6, + 'write', + $this->anything() + ) + ->willReturn(false); + + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $setup_language, + $rbac, + $language + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]); + + $this->assertTrue($result->isError()); + } + + /** + * Regression test for LanguageActivity::maybePerformAs()'s generic + * catch(\Throwable) block (InstallLanguage itself does not override + * maybePerformAs() - this exercises the base class implementation): + * a \Throwable that is NOT an \Exception (here a \TypeError, the other + * half of the \Throwable hierarchy) raised from within perform() must + * still come back as a Result\Error carrying an \Exception instance + * (here a \RuntimeException wrapping the original \TypeError as its + * "previous"), per the Activity::maybePerformAs() contract ("Wraps the + * result and possible errors in the Result type"). Before the fix, + * `new Result\Error($e)` itself raised an uncaught + * \InvalidArgumentException for such a $e (Result\Error::__construct() + * only accepts string|\Exception), so maybePerformAs() aborted instead + * of returning a Result at all. + */ + public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], []); + // checkLanguageForInstallation() is called from within perform()'s + // own loop, well after isAllowedToPerform() succeeded - a realistic + // place for an unexpected \TypeError to surface (e.g. a + // misconfigured/incompatible collaborator), simulated here directly. + $setup_language->method('checkLanguageForInstallation')->willReturnCallback( + static function (): bool { + throw new \TypeError('simulated TypeError, not an \Exception'); + } + ); + + $result = $this->createActivity($setup_language, $rbac)->maybePerformAs( + $this->createRealFieldsUiFactory()->input(), + 6, + ['language_keys' => ['de'], 'mode' => InstallLanguage::MODE_INSTALL] + ); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + private function createActivity( + ilSetupLanguage $setup_language, + ?\ilRbacSystem $rbac = null, + ?Language $language = null + ): InstallLanguage { + return new InstallLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $setup_language + ); + } + + private function createSetupLanguageMock( + array $available_languages, + array $local_language_keys, + array $installed_language_keys, + array $invalid_local_language_files = [] + ): MockObject&ilSetupLanguage { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages); + $setup_language->method('getLocalLanguages')->willReturn($local_language_keys); + $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys); + $setup_language->method('getInvalidLocalLanguageFiles')->willReturn($invalid_local_language_files); + + return $setup_language; + } +} diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php new file mode 100644 index 000000000000..f56dc2f121e3 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -0,0 +1,220 @@ +createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = new LanguageActivityThrowingInputDescriptionTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('simulated failure while building the input description', $error->getMessage()); + } + + /** + * Same scenario, but the \Throwable is not an \Exception (a \TypeError) - `new + * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a + * \RuntimeException instead of raising an uncaught \InvalidArgumentException. + */ + public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $activity = new LanguageActivityThrowingInputDescriptionTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac, + throw_type_error: true, + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertNotInstanceOf(\TypeError::class, $error); + $this->assertSame('simulated TypeError while building the input description', $error->getMessage()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + + /** + * Regression test for maybePerformAs()'s array_merge() precedence: additionalPerformParameters() + * must win over a same-named key from normalizeParameters(). Uses a dedicated LanguageActivity + * double whose normalizeParameters() always returns a colliding 'usr_id', since + * AddLanguageEntry's own getInputDescription() never collects a 'usr_id' field and so never + * actually reaches this collision. + */ + public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = new LanguageActivityMergePrecedenceTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $rbac + ); + + $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 42, []); + + $this->assertFalse($result->isError()); + $parameters = $result->value(); + // The trusted, server-resolved usr_id (42, from maybePerformAs()'s own + // argument via additionalPerformParameters()) must win over the + // spoofed value normalizeParameters() produced from (simulated) form + // input. + $this->assertSame(42, $parameters['usr_id']); + // A non-colliding key from normalizeParameters() must still survive + // the merge untouched. + $this->assertSame('form', $parameters['source']); + } +} + +/** + * Minimal concrete LanguageActivity whose getInputDescription() always + * throws, used exclusively to exercise LanguageActivity::maybePerformAs()'s + * generic try/catch around getInputDescription()/$input_factory->field() in + * isolation - every other abstract method is unreachable from + * maybePerformAs() once getInputDescription() throws, and simply throws + * itself if a test ever calls it by mistake. + */ +final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity +{ + public function __construct( + RefineryFactory $refinery, + Language $language, + \ilRbacSystem|\Closure $rbac_system, + private readonly bool $throw_type_error = false, + ) { + parent::__construct($refinery, $language, $rbac_system); + } + + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown('Test double for LanguageActivity::maybePerformAs().'); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + if ($this->throw_type_error) { + throw new \TypeError('simulated TypeError while building the input description'); + } + + throw new \RuntimeException('simulated failure while building the input description'); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + public function perform(mixed $parameters): mixed + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + protected function normalizeParameters(array $grind_result): array + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } +} + +/** + * Minimal LanguageActivity used only by + * testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): + * normalizeParameters() always returns a colliding 'usr_id', simulating spoofed form input. + * getInputDescription() returns an empty group so grind() trivially succeeds; perform() returns + * $parameters unchanged so the test can inspect what survived the merge. + */ +final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity +{ + public function getDescription(): Text\SimpleDocumentMarkdown + { + return $this->markdown('Test double for maybePerformAs() array_merge() precedence.'); + } + + public function getInputDescription(FieldFactory $f): FormInput + { + return $f->group([]); + } + + public function getOutputDescription(Description\Factory $f): Description\Description + { + throw new \LogicException(__METHOD__ . ' is not used by this test double.'); + } + + public function perform(mixed $parameters): mixed + { + return $parameters; + } + + /** + * @return array + */ + protected function normalizeParameters(array $grind_result): array + { + // Simulates a spoofed 'usr_id' smuggled in through form input - must + // never win over additionalPerformParameters()'s trusted value below. + return ['usr_id' => 'SPOOFED-VALUE', 'source' => 'form']; + } + + /** + * @return array{usr_id: int} + */ + protected function additionalPerformParameters(int $usr_id): array + { + return ['usr_id' => $usr_id]; + } +} diff --git a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php new file mode 100644 index 000000000000..589043c09d58 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php @@ -0,0 +1,94 @@ +input()` below) whose + * `->field()->text()/checkbox()/select()/group()` return REAL, functioning + * UI framework Field objects - i.e. one that GrindsFormInput::grind() (see + * the trait's own docblock) can actually run + * `withNameFrom()`/`withInput()`/`getContent()` against, instead of a plain + * PHPUnit mock, which would either return null or a broken mock object for + * these calls (and grind()'s own try/catch would silently turn every such + * call into a Result\Error). + * + * `maybePerformAs()` (see LanguageActivity/every concrete Activity) only + * ever needs `\ILIAS\UI\Component\Input\Factory` - it calls + * `$input_factory->field()` directly and never touches any other + * `\ILIAS\UI\Factory` method - so every call site here only ever chains + * `->input()` off the returned factory before using it. The helper still + * returns the full, enclosing `\ILIAS\UI\Factory` (rather than the narrower + * `\ILIAS\UI\Component\Input\Factory` directly) purely because that is the + * natural return type of the mocked `->input()` accessor below; only + * `\ILIAS\UI\Factory::input()` and `\ILIAS\UI\Component\Input\Factory::field()` + * are ever called by these tests, so those two collaborators are plain + * PHPUnit mocks stubbed to return the real, concrete field factory below - + * see CommonFieldRendering::getFieldFactory() in + * components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php + * for the same, established recipe. + */ +trait RealFieldsUiFactory +{ + /** + * Builds a `\ILIAS\UI\Factory` mock whose `input()` is a plain mock + * returning a REAL `\ILIAS\UI\Implementation\Component\Input\Field\Factory` + * from `field()` (the only two calls any Activity's `maybePerformAs()`/ + * `getInputDescription()` ever makes on it), wired against a real + * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory` pair and a + * `\ILIAS\Language\Language` mock whose `txt()` simply echoes back the + * key it was given. Callers invariably chain `->input()` on the result + * to obtain the `\ILIAS\UI\Component\Input\Factory` that `maybePerformAs()` + * actually declares as its parameter type. + */ + protected function createRealFieldsUiFactory(): \ILIAS\UI\Factory + { + $data_factory = new DataFactory(); + $lng = $this->createMock(Language::class); + $lng->method('txt')->willReturnArgument(0); + $refinery = new RefineryFactory($data_factory, $lng); + + $field_factory = new FieldFactoryImpl( + $this->createMock(NodeFactory::class), + $this->createMock(UploadLimitResolver::class), + new SignalGenerator(), + $data_factory, + $refinery, + $lng + ); + + $input = $this->createMock(\ILIAS\UI\Component\Input\Factory::class); + $input->method('field')->willReturn($field_factory); + + $ui = $this->createMock(\ILIAS\UI\Factory::class); + $ui->method('input')->willReturn($input); + + return $ui; + } +} diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php new file mode 100644 index 000000000000..60965d40f6aa --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -0,0 +1,763 @@ +buildFakeLanguageWorld(['de' => []]); + + return $this->createActivity($lng_objects, $obj_language_factory); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + /** + * @param array $language_objects + * Keyed by language key; each entry becomes a fake object with + * the given isInstalled() answer (default true) and + * removeLocalChanges() return value (default true) reachable + * from the corresponding obj_id (assigned in iteration order, + * starting at 1). + * @return array{0: array, 1: \Closure, 2: \Closure} + * [obj id => fake object, lng_objects closure, obj_language_factory closure] + */ + private function buildFakeLanguageWorld(array $language_objects): array + { + $lng_objects_list = []; + $fakes_by_obj_id = []; + $obj_id = 1; + + foreach ($language_objects as $language_key => $flags) { + $lng_objects_list[] = ['obj_id' => $obj_id, 'title' => $language_key]; + $fakes_by_obj_id[$obj_id] = new FakeRemoveLocalLanguageChangesObject( + is_installed: $flags['installed'] ?? true, + remove_local_changes_return_value: $flags['remove_local_changes_return'] ?? true, + ); + $obj_id++; + } + + $lng_objects = static fn(): array => $lng_objects_list; + $obj_language_factory = static fn(int $id): FakeRemoveLocalLanguageChangesObject => $fakes_by_obj_id[$id]; + + return [$fakes_by_obj_id, $lng_objects, $obj_language_factory]; + } + + // ----------------------------------------------------------------- + // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" + // objects sharing the same title must reject the whole request for + // that title rather than silently resolving to whichever one happened + // to be enumerated last - see UninstallLanguage's identical guard and + // UninstallLanguageTest's equivalent tests for the same reasoning. + // ----------------------------------------------------------------- + + public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void + { + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls): never { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/"de"/'); + + try { + $activity->perform(['language_keys' => 'de']); + } finally { + $this->assertSame([], $factory_calls); + } + } + + /** + * Regression test: the AmbiguousLanguageTitleException message embeds + * the ambiguous title RAW/unescaped - escaping HTML-significant + * characters for safe display is deliberately no longer this domain + * layer's job (see RemoveLocalLanguageChanges::perform()'s own comment + * above the throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + * + * NOTE (found while adding format validation coverage for + * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used + * an HTML/XSS payload ('&"quoted"') as BOTH the + * ambiguous objects' title AND the requested 'language_keys' value - + * $language_keys must equal an object's title verbatim for + * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted + * (perform() intersects the requested, already-parsed $language_keys + * against that map's keys). Since toLanguageKeyList() now rejects any + * value that is not exactly two lowercase ASCII letters BEFORE that + * intersection is even computed, such a value now throws + * InvalidInputException immediately and never reaches + * AmbiguousLanguageTitleException at all - this test would otherwise + * fail with that InvalidInputException instead of the expected + * AmbiguousLanguageTitleException. The dangerous-title scenario is + * therefore no longer reachable through perform()'s public parameter + * surface at all (a real, if incidental, hardening side effect of the + * new format check) - only a title that happens to already be a + * plausible two-letter language key can ever become "ambiguous" via a + * real request. The title/key below is changed accordingly; the + * "no HTML-escaping happens" assertions are kept for continuity, even + * though a two-letter title trivially satisfies them. + */ + public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void + { + $ambiguous_title = 'de'; + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => $ambiguous_title], + ['obj_id' => 2, 'title' => $ambiguous_title], + ]; + $obj_language_factory = static function (int $id): never { + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + try { + $activity->perform(['language_keys' => $ambiguous_title]); + $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); + } catch (AmbiguousLanguageTitleException $e) { + $this->assertStringContainsString($ambiguous_title, $e->getMessage()); + $this->assertStringNotContainsString('<script>', $e->getMessage()); + $this->assertStringNotContainsString('"', $e->getMessage()); + } + } + + /** + * Via maybePerformAs(), the same ambiguous-title \RuntimeException must + * surface as a Result\Error, not propagate as an uncaught exception - + * and, again, the object factory must never be reached for it. + */ + public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls): never { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\RuntimeException::class, $result->error()); + $this->assertSame([], $factory_calls); + } + + /** + * An ambiguous title must not poison unrelated, unambiguous titles in + * the same $lng_objects() world/request - a genuinely unique language + * key coexisting with an ambiguous one must still resolve and have its + * local changes removed normally. + */ + public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void + { + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $fr = new FakeRemoveLocalLanguageChangesObject(is_installed: true); + $obj_language_factory = static function (int $id) use ($fr): FakeRemoveLocalLanguageChangesObject { + if ($id === 3) { + return $fr; + } + + throw new \LogicException('must never be called for the ambiguous "de" title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + $result = $activity->perform(['language_keys' => 'fr']); + + $this->assertSame(['fr'], $result['removed_local_changes_language_keys']); + $this->assertSame(1, $fr->removeLocalChangesCallCount()); + } + + /** + * Regression test for the ambiguity check being moved OUT of the + * per-key foreach loop and performed for ALL requested keys BEFORE any + * removeLocalChanges() call (see the comment directly above that check + * in perform(): "Checked upfront, for all requested keys at once, so a + * request naming both an unambiguous and an ambiguous key never + * changes the unambiguous one before rejecting the whole call."). A + * request naming an unambiguous key ('de') FIRST and an ambiguous one + * ('fr') SECOND must reject the whole request and must NOT have already + * changed 'de' by the time 'fr' is reached - before this fix, the + * ambiguity check ran inline inside the loop, so 'de' (processed first) + * would already have had its local changes removed for real before the + * loop reached the ambiguous 'fr' and threw. + */ + public function testAnUnambiguousKeyBeforeAnAmbiguousOneNeverHasLocalChangesRemovedOnceTheWholeRequestIsRejected(): void + { + $de = new FakeRemoveLocalLanguageChangesObject(is_installed: true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'fr'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls, $de): FakeRemoveLocalLanguageChangesObject { + $factory_calls[] = $id; + if ($id === 1) { + return $de; + } + + throw new \LogicException('must never be called for the ambiguous "fr" title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + try { + // 'de' listed BEFORE the ambiguous 'fr' on purpose - see the + // "Checked upfront, for all requested keys at once" comment + // above the ambiguity check in perform(). + $activity->perform(['language_keys' => 'de,fr']); + $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); + } catch (AmbiguousLanguageTitleException $e) { + $this->assertStringContainsString('"fr"', $e->getMessage()); + // 'de' was never resolved to a factory call, let alone changed - + // the ambiguity check ran BEFORE the per-key loop. + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->removeLocalChangesCallCount()); + } + } + + /** + * Same regression, exercised end-to-end through maybePerformAs(): the + * Result\Error must surface without 'de' having had its local changes + * removed. + */ + public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $de = new FakeRemoveLocalLanguageChangesObject(is_installed: true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'fr'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $obj_language_factory = static function (int $id) use ($de): FakeRemoveLocalLanguageChangesObject { + if ($id === 1) { + return $de; + } + + throw new \LogicException('must never be called for the ambiguous "fr" title'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(AmbiguousLanguageTitleException::class, $result->error()); + $this->assertSame(0, $de->removeLocalChangesCallCount()); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac, + null, + 42 + ); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac + ); + + $this->assertFalse($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + /** + * isAllowedToPerform() must be a pure permission check: it must never + * touch $lng_objects or $obj_language_factory (and therefore never call + * removeLocalChanges()) - unlike perform(), which is the only place + * allowed to have side effects. + */ + public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): void + { + $lng_objects = static function (): array { + throw new \LogicException('lng_objects must never be called by isAllowedToPerform()'); + }; + $obj_language_factory = static function (int $id) { + throw new \LogicException('obj_language_factory must never be called by isAllowedToPerform()'); + }; + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity($lng_objects, $obj_language_factory, $rbac); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + public function testInstalledLanguageWithValidFileHasItsLocalChangesRemoved(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame(['de'], $result['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalledWithoutCallingRemoveLocalChanges(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['installed' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + $this->assertSame(0, $fakes[1]->removeLocalChangesCallCount()); + } + + /** + * Regression test: removeLocalChanges() itself is the authority on + * whether the language file was valid, not merely the outer + * isInstalled() guard. A language that passes isInstalled() (so + * perform() does call removeLocalChanges()) but whose + * removeLocalChanges() nonetheless returns false (invalid language + * file) must be reported as invalid_language_file_keys, never as + * removed_local_changes_language_keys or not_installed_language_keys. + */ + public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInvalidLanguageFile(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['remove_local_changes_return' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['removed_local_changes_language_keys']); + $this->assertSame(['de'], $result['invalid_language_file_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + // removeLocalChanges() must still have been attempted. + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + /** + * A language key with no corresponding "lng" object at all must be + * reported as not installed - and, crucially, $obj_language_factory + * must never even be invoked for it (there is no obj_id to construct it + * from in the first place). + */ + public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingTheObjectFactory(): void + { + $lng_objects = static fn(): array => []; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls) { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an unknown language key'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'xx', + ]); + + $this->assertSame([], $result['removed_local_changes_language_keys']); + $this->assertSame([], $result['invalid_language_file_keys']); + $this->assertSame(['xx'], $result['not_installed_language_keys']); + $this->assertSame([], $factory_calls); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and removeLocalChanges() must only + * ever be invoked for the two genuinely installed languages (regardless + * of whether their file turns out valid or not) - never for the + * not-installed or unknown ones. + */ + public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => ['remove_local_changes_return' => false], + 'it' => ['installed' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + // 'xx' has no backing object at all. + 'language_keys' => ['de', 'fr', 'it', 'xx'], + ]); + + $this->assertSame(['de'], $result['removed_local_changes_language_keys']); + $this->assertSame(['fr'], $result['invalid_language_file_keys']); + $this->assertSame(['it', 'xx'], $result['not_installed_language_keys']); + + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount(), 'de must have its local changes removed'); + $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount(), 'fr is installed, so must still be attempted'); + $this->assertSame(0, $fakes[3]->removeLocalChangesCallCount(), 'not-installed language must never be attempted'); + } + + public function testDuplicateLanguageKeysAcrossStringAndArrayAreDeduplicated(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => [' de, de ', 'de'], + ]); + + $this->assertSame(['de'], $result['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + public function testCommaSeparatedStringIsNormalizedIntoMultipleLanguageKeys(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => [], + 'it' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => ' de, fr ,it', + ]); + + $this->assertSame(['de', 'fr', 'it'], $result['removed_local_changes_language_keys']); + } + + public static function invalidLanguageKeysProvider(): array + { + return [ + 'missing language_keys key' => [[]], + 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], + 'nested array value' => [['language_keys' => ['de', ['fr']]]], + 'non-array, non-string value' => [['language_keys' => 42]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + 'three letters' => [['language_keys' => 'deu']], + 'one letter' => [['language_keys' => 'd']], + 'uppercase' => [['language_keys' => 'DE']], + 'contains a digit' => [['language_keys' => 'de1']], + 'contains a hyphen' => [['language_keys' => 'de-at']], + ]; + } + + #[DataProvider('invalidLanguageKeysProvider')] + public function testInvalidLanguageKeysAreRejected(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see RemoveLocalLanguageChanges::perform(), ~line 143): this check was + * switched from a plain \InvalidArgumentException to the concrete + * InvalidInputException, to be consistent with AddLanguageEntry, + * SetLanguageDetectionEnabled and SetLanguageTranslationEnabled, which + * already used it. The docblock/comment that used to justify the + * opposite ("deliberately NOT an InvalidInputException") is stale and + * has been removed - this test now pins the CURRENT, more specific + * exception class, not just \InvalidArgumentException (which + * InvalidInputException extends, and against which this assertion would + * therefore have kept passing silently even after the behaviour change - + * see RendersActivityErrors, which treats InvalidInputException/ + * SafeToDisplayActivityError specially). + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + /** + * Regression test analogous to + * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): + * a denied permission must short-circuit before perform() ever runs - + * $lng_objects/$obj_language_factory (and therefore removeLocalChanges()) + * must never be touched. + */ + public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac, + $language + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + $this->assertSame(0, $fakes[1]->removeLocalChangesCallCount()); + } + + public function testPermissionGrantedPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertFalse($result->isError()); + $this->assertSame(['de'], $result->value()['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalChangesForEachOne(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]); + + $this->assertFalse($result->isError()); + $this->assertSame(['de', 'fr'], $result->value()['removed_local_changes_language_keys']); + $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); + $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount()); + } + + /** + * A Throwable raised from within perform() (e.g. a validation failure + * on the normalized parameters, or any other unexpected failure) must + * be turned into a Result\Error rather than propagating - this is the + * whole point of maybePerformAs() wrapping perform() in a try/catch. + */ + public function testThrowableFromPerformIsTurnedIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $lng_objects = static fn(): array => []; + $obj_language_factory = static fn(int $id) => null; + + // An empty language_keys value normalizes to [] and throws inside + // perform() (via toLanguageKeyList()) - reachable only after the + // permission check has already passed, exercising the catch block. + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ' , ']); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * A completely missing 'language_keys' key is now caught by grind() + * itself (see GrindsFormInput): the required Text field receives a + * blank raw value and fails its own required-field constraint before + * normalizeParameters()/isAllowedToPerform() are ever reached - so the + * rejection surfaces as a Result\Error carrying the UI field's own + * (string) validation message, not an InvalidArgumentException thrown + * by normalizeParameters(). Either way, the rbac system must never be + * touched (validation happens first). + */ + public function testMissingLanguageKeysParameterViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $result = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + private function createActivity( + \Closure $lng_objects, + \Closure $obj_language_factory, + ?\ilRbacSystem $rbac = null, + ?Language $language = null, + int $language_folder_ref_id = 0 + ): RemoveLocalLanguageChanges { + return new RemoveLocalLanguageChanges( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $language_folder_ref_id, + $lng_objects, + $obj_language_factory + ); + } +} + +/** + * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or + * meaningfully mocked in a unit test (its constructor requires the global + * $DIC), so tests substitute this fake via RemoveLocalLanguageChanges's + * $obj_language_factory closure instead. Counts removeLocalChanges() + * invocations so tests can assert it was never called for a bucket other + * than "removed"/"invalid". + */ +final class FakeRemoveLocalLanguageChangesObject +{ + private int $remove_local_changes_calls = 0; + + /** + * @param bool $remove_local_changes_return_value What removeLocalChanges() + * returns - true mimics a real, successful removal of local + * changes; false mimics \ilObjLanguage::removeLocalChanges() + * rejecting the call because the underlying language file failed + * check() (isInstalled() having already let it through). + */ + public function __construct( + private readonly bool $is_installed, + private readonly bool $remove_local_changes_return_value = true, + ) { + } + + public function isInstalled(): bool + { + return $this->is_installed; + } + + public function removeLocalChanges(): bool + { + $this->remove_local_changes_calls++; + + return $this->remove_local_changes_return_value; + } + + public function removeLocalChangesCallCount(): int + { + return $this->remove_local_changes_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php new file mode 100644 index 000000000000..c2f4abfaca69 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -0,0 +1,445 @@ +createActivity(); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity(rbac_system: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + /** + * isAllowedToPerform() must be a pure permission check: it must never + * touch the Setting collaborator (and therefore never write + * "lang_detection") - unlike perform(), which is the only place + * allowed to have side effects. + */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, settings: $settings); + + $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); + } + + // ----------------------------------------------------------------- + // perform() + // ----------------------------------------------------------------- + + public function testPerformWithEnabledTrueWritesOneIntoLangDetectionSettingAndReturnsEnabledTrue(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('set') + ->with('lang_detection', '1'); + + $result = $this->createActivity(settings: $settings)->perform(['enabled' => true]); + + $this->assertSame(['enabled' => true], $result); + } + + public function testPerformWithEnabledFalseWritesZeroIntoLangDetectionSettingAndReturnsEnabledFalse(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('set') + ->with('lang_detection', '0'); + + $result = $this->createActivity(settings: $settings)->perform(['enabled' => false]); + + $this->assertSame(['enabled' => false], $result); + } + + public function testPerformRejectsMissingParametersArray(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform([]); + } + + public function testPerformRejectsNonArrayParameters(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform('not-an-array'); + } + + /** + * Boundary/type-juggling regression: perform() must require a strict + * bool for 'enabled' and must never silently cast a truthy/falsy + * look-alike (string "1"/"0"/"true", int 1, etc.) - unlike PHP's own + * loose comparison rules, which would happily accept any of these. + */ + #[DataProvider('nonBooleanEnabledValuesProvider')] + public function testPerformRejectsNonStrictBooleanEnabledValues(mixed $value): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform(['enabled' => $value]); + } + + public static function nonBooleanEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 1' => [1], + 'int 0' => [0], + 'float 1.0' => [1.0], + 'null' => [null], + 'array' => [[true]], + ]; + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** + * Core security regression test: before this extraction, + * enableLanguageDetectionObject()/disableLanguageDetectionObject() + * performed no RBAC check of their own at all - a request forged + * directly against the corresponding ilCtrl + * commands by a user with only "read" access on the language folder + * would still have flipped "lang_detection". isAllowedToPerform() now + * enforces a "write" check, and this must actually cause + * maybePerformAs() to reject the request (Result\Error) *and* leave the + * Setting collaborator completely untouched - a permission check that + * merely rejected the outcome without preventing the side effect would + * not close the gap at all. + */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings, language: $language) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); + + $this->assertTrue($result->isError()); + } + + /** + * A Throwable raised from within perform() (or normalizeParameters()) + * must be turned into a Result\Error rather than propagating - this is + * the whole point of maybePerformAs() wrapping perform() in a + * try/catch. + */ + public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => 'not-a-bool']); + + $this->assertTrue($result->isError()); + // 'not-a-bool' is outside normalizeCheckboxRawValue()'s explicit + // whitelist - grind()'s dedicated catch block converts the + // resulting \InvalidArgumentException into an InvalidInputException + // (a SafeToDisplayActivityError), not a raw \InvalidArgumentException. + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * The 'enabled' Checkbox field is not required, so - now that + * maybePerformAs() actually grinds $raw_parameters through + * getInputDescription() (see GrindsFormInput) - a completely missing + * 'enabled' key is no longer rejected: it defaults to false, exactly + * like an unchecked, and therefore never submitted, HTML checkbox + * would. This is a deliberate behavioural change from the old + * (pre-grinding) normalizeParameters(), which used to reject a missing + * key outright. Unlike before, the request now actually reaches + * isAllowedToPerform()/perform(). + */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings)->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => false], $result->value()); + } + + public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'int 1' => [1], + ]; + } + + public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array + { + return [ + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 0' => [0], + 'null' => [null], + ]; + } + + /** + * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): + * unlike perform() itself (which still requires a strict bool, see + * testPerformRejectsNonStrictBooleanEnabledValues), maybePerformAs() + * now accepts the common primitive representations of "true" a generic + * (non-HTML) caller might reasonably send, and writes the setting + * accordingly. + */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsAndWritesEnabledTrue(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => true], $result->value()); + } + + /** + * Same tolerance as above, but for the common primitive representations + * of "false". + */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->once())->method('set')->with('lang_detection', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame(['enabled' => false], $result->value()); + } + + public static function rejectedEnabledValuesProvider(): array + { + return [ + 'float 1.0' => [1.0], + 'array' => [[true]], + ]; + } + + /** + * Only values outside GrindsFormInput::normalizeCheckboxRawValue()'s + * explicit whitelist are still rejected via maybePerformAs() - "konservativ + * normalisieren", not "alles akzeptieren". + */ + #[DataProvider('rejectedEnabledValuesProvider')] + public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWithoutTouchingSettings(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => $value]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + // ----------------------------------------------------------------- + // getInputDescription() / getOutputDescription() + // ----------------------------------------------------------------- + + public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void + { + $checkbox = $this->createMock(Checkbox::class); + $checkbox->expects($this->once()) + ->method('withDedicatedName') + ->with('enabled') + ->willReturnSelf(); + + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->expects($this->once()) + ->method('checkbox') + ->with( + 'Enabled', + 'Whether automatic language detection from the browser should be enabled.' + ) + ->willReturn($checkbox); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['enabled' => $checkbox]) + ->willReturn($group); + + $activity = $this->createActivity(); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void + { + $bool_description = $this->createMock(Description::class); + $object_description = $this->createMock(Description::class); + + $f = $this->createMock(\ILIAS\Data\Description\Factory::class); + $f->expects($this->once())->method('bool')->willReturn($bool_description); + $f->expects($this->once()) + ->method('object') + ->with($this->anything(), ['enabled' => $bool_description]) + ->willReturn($object_description); + + $this->assertSame($object_description, $this->createActivity()->getOutputDescription($f)); + } + + // ----------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------- + + private function createActivity( + ?\ilRbacSystem $rbac_system = null, + ?Setting $settings = null, + ?Language $language = null, + int $language_folder_ref_id = 0 + ): SetLanguageDetectionEnabled { + return new SetLanguageDetectionEnabled( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac_system ?? $this->createMock(\ilRbacSystem::class), + $settings ?? $this->createMock(Setting::class), + $language_folder_ref_id + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php new file mode 100644 index 000000000000..0ed340c0067f --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -0,0 +1,718 @@ +", not a single fixed key), and perform() itself + * (not just normalizeParameters()) validates its $parameters array and + * computes a `changed` flag by comparing the requested value against the + * *currently stored* one (read via a falsy (bool) cast, exactly mirroring + * `ilObjLanguageAccess::_checkTranslate()` - see `$currently_enabled = (bool) + * ($this->settings)()->get($translate_key, '0')` in perform()). That + * comparison, and the "write only if changed" behaviour it drives, is the + * most important regression surface in this file: a mutant that always + * writes (or that inverts the comparison) must be caught here. + * + * The second most important regression this file guards is the same one as + * SetLanguageDetectionEnabledTest: the enforced "write" RBAC check in + * isAllowedToPerform()/maybePerformAs() must actually prevent both the + * outcome *and* the side effect (no write to Settings) when denied. + */ +class SetLanguageTranslationEnabledTest extends ActivityContractTestCase +{ + protected function createDefaultActivity(): SetLanguageTranslationEnabled + { + return $this->createActivity(); + } + + // ----------------------------------------------------------------- + // isAllowedToPerform() + // ----------------------------------------------------------------- + + public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', 42) + ->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, language_folder_ref_id: 42); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(false); + + $activity = $this->createActivity(rbac_system: $rbac); + + $this->assertFalse($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + /** + * isAllowedToPerform() must be a pure permission check: it must never + * touch the Setting collaborator (and therefore never read/write + * "lang_translate_*") - unlike perform(), which is the only place + * allowed to have side effects. + */ + public function testIsAllowedToPerformNeverTouchesSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $activity = $this->createActivity(rbac_system: $rbac, settings: $settings); + + $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); + } + + // ----------------------------------------------------------------- + // perform() - the changed/unchanged decision + // ----------------------------------------------------------------- + + /** + * Core business rule: perform() must read the + * *current* value of "lang_translate_" via a falsy check - exactly + * the same interpretation ilObjLanguageAccess::_checkTranslate() applies + * - and only write when the requested value actually differs. Every + * combination of a realistic stored raw value ("0", "", "1") and a + * requested boolean is exercised so that both an accidental "always + * write" and an accidental "never write" mutant are caught, along with + * an inverted `!==` -> `===` comparison mutant. + */ + public static function performChangedDecisionProvider(): array + { + return [ + 'stored "0", request true -> changed, write "1"' => ['0', true, true, '1'], + 'stored "0", request false -> unchanged, no write' => ['0', false, false, null], + 'stored "", request true -> changed, write "1" (falsy like "0")' => ['', true, true, '1'], + 'stored "", request false -> unchanged, no write (falsy like "0")' => ['', false, false, null], + 'stored "1", request true -> unchanged, no write' => ['1', true, false, null], + 'stored "1", request false -> changed, write "0"' => ['1', false, true, '0'], + ]; + } + + #[DataProvider('performChangedDecisionProvider')] + public function testPerformComparesRequestedValueAgainstFalsyCastOfCurrentlyStoredValue( + string $stored_value, + bool $requested_enabled, + bool $expected_changed, + ?string $expected_written_value + ): void { + $settings = $this->createMock(Setting::class); + $settings->method('get') + ->with('lang_translate_de', '0') + ->willReturn($stored_value); + + if ($expected_written_value === null) { + $settings->expects($this->never())->method('set'); + } else { + $settings->expects($this->once()) + ->method('set') + ->with('lang_translate_de', $expected_written_value); + } + + $result = $this->createActivity(settings: $settings) + ->perform(['language_key' => 'de', 'enabled' => $requested_enabled]); + + $this->assertSame( + ['language_key' => 'de', 'enabled' => $requested_enabled, 'changed' => $expected_changed], + $result + ); + } + + /** + * get() must be called with the correct per-language key + * ("lang_translate_" . language_key) and the documented default '0' - + * a mutant that drops the default, or concatenates the key in the wrong + * order/without the separator, must be caught here. + */ + public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->once()) + ->method('get') + ->with('lang_translate_fr', '0') + ->willReturn('0'); + + $this->createActivity(settings: $settings)->perform(['language_key' => 'fr', 'enabled' => false]); + } + + /** + * language_key must be trimmed before being used to build the setting + * key and before being returned - mirroring toNonEmptyString()'s + * behaviour used by normalizeParameters(), but exercised here directly + * through perform() since perform() re-implements its own trim(). + */ + public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void + { + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(settings: $settings) + ->perform(['language_key' => ' de ', 'enabled' => true]); + + $this->assertSame('de', $result['language_key']); + } + + // ----------------------------------------------------------------- + // perform() - parameter validation + // ----------------------------------------------------------------- + + public static function invalidPerformParametersProvider(): array + { + return [ + 'non-array parameters' => ['not-an-array'], + 'empty array' => [[]], + 'missing language_key' => [['enabled' => true]], + 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], + 'empty string language_key' => [['language_key' => '', 'enabled' => true]], + 'whitespace-only language_key' => [['language_key' => ' ', 'enabled' => true]], + 'missing enabled' => [['language_key' => 'de']], + 'non-bool enabled: string "1"' => [['language_key' => 'de', 'enabled' => '1']], + 'non-bool enabled: string "true"' => [['language_key' => 'de', 'enabled' => 'true']], + 'non-bool enabled: string ""' => [['language_key' => 'de', 'enabled' => '']], + 'non-bool enabled: int 1' => [['language_key' => 'de', 'enabled' => 1]], + 'non-bool enabled: int 0' => [['language_key' => 'de', 'enabled' => 0]], + 'non-bool enabled: float 1.0' => [['language_key' => 'de', 'enabled' => 1.0]], + 'non-bool enabled: null' => [['language_key' => 'de', 'enabled' => null]], + 'non-bool enabled: array' => [['language_key' => 'de', 'enabled' => [true]]], + ]; + } + + #[DataProvider('invalidPerformParametersProvider')] + public function testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings(mixed $parameters): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings)->perform($parameters); + } + + // ----------------------------------------------------------------- + // maybePerformAs() + // ----------------------------------------------------------------- + + public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => true, 'changed' => true], + $result->value() + ); + } + + /** + * Permission check regression: a denied "write" access must both yield + * a Result\Error *and* leave the Setting collaborator completely + * untouched (no read, no write) - a permission check that merely + * rejected the outcome without preventing the read/write would not be a + * real gate. + */ + public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouchesSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings, language: $language) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertSame('no write permission', $result->error()); + } + + /** + * A Throwable raised from within perform() (e.g. the Setting + * collaborator failing to write) must be turned into a Result\Error + * rather than propagating - this is the whole point of maybePerformAs() + * wrapping perform() in a try/catch. + */ + public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->willReturn('0'); + $settings->method('set')->willThrowException(new \RuntimeException('database write failed')); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $error = $result->error(); + $this->assertInstanceOf(\RuntimeException::class, $error); + $this->assertSame('database write failed', $error->getMessage()); + } + + /** + * A completely missing 'language_key' key is caught by grind() itself + * (see GrindsFormInput): the required Text field receives a blank raw + * value and fails its own required-field constraint before + * normalizeParameters()/isAllowedToPerform() are ever reached - so the + * rejection surfaces as a Result\Error carrying a field-attributed + * InvalidInputException (see GrindsFormInput::describeInputError()), not + * an InvalidArgumentException. The rbac system must never be touched + * either way (validation happens first). + */ + public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndNeverChecksPermission(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $result = $this->createActivity(rbac_system: $rbac)->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array + { + return [ + // The required 'language_key' Text field receives a blank raw + // value (missing entirely) and fails its own required-field + // constraint inside grind() itself - before normalizeParameters()/ + // isAllowedToPerform() are ever reached. + 'missing language_key' => [['enabled' => true]], + ]; + } + + #[DataProvider('invalidRawParametersRejectedByGrindWithStringErrorProvider')] + public function testMaybePerformAsRejectsRawParametersAtGrindLevelWithStringErrorWithoutTouchingSettingsOrRbac( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertNotSame('', $result->error()->getMessage()); + } + + public static function invalidRawParametersProvider(): array + { + return [ + // A non-string 'language_key' fails the Text field's own + // isClientSideValueOk() check inside grind() itself, which + // throws an InvalidArgumentException ("Display value does not + // match input type."), caught and preserved by grind()'s + // try/catch. + 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], + // Outside GrindsFormInput::normalizeCheckboxRawValue()'s + // explicit whitelist - "konservativ normalisieren", not "alles + // akzeptieren". + 'non-bool enabled: array' => [['language_key' => 'de', 'enabled' => [true]]], + 'non-bool enabled: float 1.0' => [['language_key' => 'de', 'enabled' => 1.0]], + ]; + } + + #[DataProvider('invalidRawParametersProvider')] + public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithoutTouchingSettingsOrRbac( + array $raw_parameters + ): void { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->never())->method('checkAccessOfUser'); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, $raw_parameters); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * The 'enabled' Checkbox field is not required, so a completely + * missing 'enabled' key is no longer rejected by grind() - it defaults + * to false, exactly like an unchecked, and therefore never submitted, + * HTML checkbox would (same behaviour as + * SetLanguageDetectionEnabledTest's equivalent regression test). + * Unlike a rejection at grind level, this reaches isAllowedToPerform()/ + * perform() and can still write the setting if that changes the + * currently stored value. + */ + public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('1'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de']); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => false, 'changed' => true], + $result->value() + ); + } + + /** + * A whitespace-only language_key value satisfies the Text field's own + * required-check (min length 1 on the raw string, before trimming), so + * grind() accepts it and the request DOES reach isAllowedToPerform() - + * unlike the fully-missing-key case above. It is perform() itself + * (via its own trim() check, see + * testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings) + * that ultimately rejects it. + */ + public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermissionCheck(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' ', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array + { + return [ + 'string "1"' => ['1'], + 'string "true"' => ['true'], + 'int 1' => [1], + ]; + } + + public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array + { + return [ + 'string "0"' => ['0'], + 'string "false"' => ['false'], + 'string ""' => [''], + 'int 0' => [0], + 'null' => [null], + ]; + } + + /** + * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): + * unlike perform() itself (which still requires a strict bool), + * maybePerformAs() now accepts the common primitive representations of + * "true" a generic (non-HTML) caller might reasonably send. + */ + #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => true, 'changed' => true], + $result->value() + ); + } + + /** + * Same tolerance as above, but for the common primitive representations + * of "false". + */ + #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] + public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('1'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '0'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => $value]); + + $this->assertFalse($result->isError()); + $this->assertSame( + ['language_key' => 'de', 'enabled' => false, 'changed' => true], + $result->value() + ); + } + + // ----------------------------------------------------------------- + // language_key must be an installed language (Punkt 4) + // ----------------------------------------------------------------- + + /** + * Regression test for Punkt 4 of this component's grinding task: unlike + * before, an unknown/not-installed language_key is now rejected by + * perform() itself, for direct perform()/isAllowedToPerform() callers + * too - not only via maybePerformAs(). + */ + public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $this->expectException(InvalidInputException::class); + + $this->createActivity(settings: $settings) + ->perform(['language_key' => 'xx', 'enabled' => true]); + } + + /** + * Regression test: the "Unknown language key" InvalidInputException + * message embeds the offending language_key RAW/unescaped - escaping + * HTML-significant characters for safe display is deliberately no + * longer this domain layer's job (see perform()'s own comment above the + * throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + */ + public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescapedKey(): void + { + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $dangerous_key = '&"quoted"'; + + try { + $this->createActivity(settings: $settings) + ->perform(['language_key' => $dangerous_key, 'enabled' => true]); + $this->fail('Expected an InvalidInputException to be thrown.'); + } catch (InvalidInputException $e) { + $this->assertStringContainsString($dangerous_key, $e->getMessage()); + $this->assertStringNotContainsString('<script>', $e->getMessage()); + $this->assertStringNotContainsString('"', $e->getMessage()); + } + } + + /** + * The same rejection, exercised end-to-end through maybePerformAs(): + * the unknown language_key passes grind() (any non-blank string + * satisfies the Text field's own required-check) and the permission + * check (isAllowedToPerform() does not know about installed languages + * either), so it is perform() itself that rejects it - and the Setting + * collaborator must never be touched. + */ + public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckAndNeverTouchesSettings(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->expects($this->never())->method('get'); + $settings->expects($this->never())->method('set'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'xx', 'enabled' => true]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(InvalidInputException::class, $result->error()); + $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + } + + /** + * normalizeParameters()'s toNonEmptyString() must trim language_key + * before it reaches perform() - exercised end-to-end through + * maybePerformAs() so a caller submitting e.g. a copy-pasted language + * key with surrounding whitespace still resolves to the same setting + * key as perform() itself would (see + * testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue). + */ + public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $settings = $this->createMock(Setting::class); + $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); + $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + + $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' de ', 'enabled' => true]); + + $this->assertFalse($result->isError()); + $this->assertSame('de', $result->value()['language_key']); + } + + // ----------------------------------------------------------------- + // getInputDescription() / getOutputDescription() / getDescription() + // ----------------------------------------------------------------- + + public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFieldAndAnEnabledCheckbox(): void + { + $text = $this->createMock(Text::class); + $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $text->expects($this->once())->method('withDedicatedName')->with('language_key')->willReturnSelf(); + + $checkbox = $this->createMock(Checkbox::class); + $checkbox->expects($this->once()) + ->method('withDedicatedName') + ->with('enabled') + ->willReturnSelf(); + + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->expects($this->once()) + ->method('text') + ->with( + 'Language key', + 'Language key the page translation setting applies to, e.g. de, fr, it.' + ) + ->willReturn($text); + $field->expects($this->once()) + ->method('checkbox') + ->with( + 'Enabled', + 'Whether page translation should be enabled for this language.' + ) + ->willReturn($checkbox); + + $group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_key' => $text, 'enabled' => $checkbox]) + ->willReturn($group); + + $activity = $this->createActivity(); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + /** + * Uses a real DescriptionFactory (like AddLanguageEntryTest's own + * output-description test) rather than a mocked one, so that + * getFields()'s *actual* iteration order is asserted - a mocked + * object()->with([...]) call would accept the three fields in any + * order (PHPUnit's array comparison is order-independent), silently + * missing a mutation that swapped e.g. 'enabled' and 'changed'. + */ + public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): void + { + $string_group = $this->createMock(StringGroup::class); + $string_group->method('markdown')->willReturn( + $this->createMock(MarkdownFormattingToHTML::class) + ); + $refinery = $this->createMock(RefineryFactory::class); + $refinery->method('string')->willReturn($string_group); + + $activity = $this->createActivity(refinery: $refinery); + + $description = $activity->getOutputDescription(new DescriptionFactory()); + + $field_names = []; + foreach ($description->getFields() as $field) { + $field_names[] = $field->getName(); + } + + $this->assertSame(['language_key', 'enabled', 'changed'], $field_names); + } + + // ----------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------- + + private function createActivity( + ?\ilRbacSystem $rbac_system = null, + ?Setting $settings = null, + ?Language $language = null, + int $language_folder_ref_id = 0, + ?RefineryFactory $refinery = null, + ?InstalledLanguageRepository $installed_language_repository = null, + ): SetLanguageTranslationEnabled { + return new SetLanguageTranslationEnabled( + $refinery ?? $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac_system ?? $this->createMock(\ilRbacSystem::class), + $settings ?? $this->createMock(Setting::class), + installed_language_repository: $installed_language_repository + ?? new FakeInstalledLanguageRepository(static fn(): array => ['de', 'fr']), + language_folder_ref_id: $language_folder_ref_id, + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php new file mode 100644 index 000000000000..2375f535ae23 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -0,0 +1,750 @@ +buildFakeLanguageWorld(['de' => []]); + + return $this->createActivity($lng_objects, $obj_language_factory); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + // ----------------------------------------------------------------- + // maybePerformAs() turning a \Throwable from perform() into a + // Result\Error (m5) - here, the new ambiguous-title \RuntimeException + // from resolveObjIdsByLanguageKey() (see below) doubles as the concrete + // \Throwable this class' perform() can actually raise. + // ----------------------------------------------------------------- + + public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePerformAs(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ]; + $obj_language_factory = static function (int $id): never { + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\RuntimeException::class, $result->error()); + } + + // ----------------------------------------------------------------- + // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" + // objects sharing the same title must reject the whole request for + // that title rather than silently resolving to whichever one happened + // to be enumerated last. + // ----------------------------------------------------------------- + + public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void + { + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls): never { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/"de"/'); + + try { + $activity->perform(['language_keys' => 'de']); + } finally { + $this->assertSame([], $factory_calls); + } + } + + /** + * Regression test: the AmbiguousLanguageTitleException message embeds + * the ambiguous title RAW/unescaped - escaping HTML-significant + * characters for safe display is deliberately no longer this domain + * layer's job (see UninstallLanguage::perform()'s own docblock/comment + * above the throw), only \ILIAS\Language\RendersActivityErrors:: + * activityErrorMessage()'s. If this message were pre-escaped here, it + * would end up double-escaped once it reaches that single seam. + * + * NOTE (found while adding format validation coverage for + * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used + * an HTML/XSS payload ('&"quoted"') as BOTH the + * ambiguous objects' title AND the requested 'language_keys' value - + * $language_keys must equal an object's title verbatim for + * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted + * (perform() intersects the requested, already-parsed $language_keys + * against that map's keys). Since toLanguageKeyList() now rejects any + * value that is not exactly two lowercase ASCII letters BEFORE that + * intersection is even computed, such a value now throws + * InvalidInputException immediately and never reaches + * AmbiguousLanguageTitleException at all - this test would otherwise + * fail with that InvalidInputException instead of the expected + * AmbiguousLanguageTitleException. The dangerous-title scenario is + * therefore no longer reachable through perform()'s public parameter + * surface at all (a real, if incidental, hardening side effect of the + * new format check) - only a title that happens to already be a + * plausible two-letter language key can ever become "ambiguous" via a + * real request. The title/key below is changed accordingly; the + * "no HTML-escaping happens" assertions are kept for continuity, even + * though a two-letter title trivially satisfies them. + */ + public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void + { + $ambiguous_title = 'de'; + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => $ambiguous_title], + ['obj_id' => 2, 'title' => $ambiguous_title], + ]; + $obj_language_factory = static function (int $id): never { + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + try { + $activity->perform(['language_keys' => $ambiguous_title]); + $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); + } catch (AmbiguousLanguageTitleException $e) { + $this->assertStringContainsString($ambiguous_title, $e->getMessage()); + $this->assertStringNotContainsString('<script>', $e->getMessage()); + $this->assertStringNotContainsString('"', $e->getMessage()); + } + } + + /** + * Via maybePerformAs(), the same ambiguous-title \RuntimeException must + * surface as a Result\Error, not propagate as an uncaught exception - + * and, again, the object factory must never be reached for it. + */ + public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls): never { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an ambiguous title'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(\RuntimeException::class, $result->error()); + $this->assertSame([], $factory_calls); + } + + /** + * An ambiguous title must not poison unrelated, unambiguous titles in + * the same $lng_objects() world/request - a genuinely unique language + * key coexisting with an ambiguous one must still resolve and uninstall + * normally. + */ + public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void + { + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'de'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $fr = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true); + $obj_language_factory = static function (int $id) use ($fr): FakeLanguageObject { + if ($id === 3) { + return $fr; + } + + throw new \LogicException('must never be called for the ambiguous "de" title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + $result = $activity->perform(['language_keys' => 'fr']); + + $this->assertSame(['fr'], $result['uninstalled_language_keys']); + $this->assertSame(1, $fr->uninstallCallCount()); + } + + /** + * Regression test for the ambiguity check being moved OUT of the + * per-key foreach loop and performed for ALL requested keys BEFORE any + * uninstall() call (see the comment directly above the + * AmbiguousLanguageTitleException throw in UninstallLanguage::perform(): + * "Checked upfront, for all requested keys at once, so a request naming + * both an unambiguous and an ambiguous key never uninstalls the + * unambiguous one before rejecting the whole call"). A request naming + * an unambiguous key ('de') FIRST and an ambiguous one ('fr') SECOND + * must reject the whole request and must NOT have already uninstalled + * 'de' by the time 'fr' is reached - before this fix, the ambiguity + * check ran inline inside the loop, so 'de' (processed first) would + * already have been uninstalled for real before the loop reached the + * ambiguous 'fr' and threw. + */ + public function testAnUnambiguousKeyBeforeAnAmbiguousOneIsNeverUninstalledOnceTheWholeRequestIsRejected(): void + { + $de = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'fr'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls, $de): FakeLanguageObject { + $factory_calls[] = $id; + if ($id === 1) { + return $de; + } + + throw new \LogicException('must never be called for the ambiguous "fr" title'); + }; + + $activity = $this->createActivity($lng_objects, $obj_language_factory); + + try { + // 'de' listed BEFORE the ambiguous 'fr' on purpose - this is the + // exact ordering the regression above (see this test's own + // docblock) requires: were the ambiguity check still inline in + // the per-key loop, 'de' being processed first would already + // have been uninstalled before 'fr' was ever reached. + $activity->perform(['language_keys' => 'de,fr']); + $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); + } catch (AmbiguousLanguageTitleException $e) { + $this->assertStringContainsString('"fr"', $e->getMessage()); + // 'de' was never resolved to a factory call, let alone + // uninstalled - the ambiguity check ran BEFORE the per-key loop. + $this->assertSame([], $factory_calls); + $this->assertSame(0, $de->uninstallCallCount()); + } + } + + /** + * Same regression, exercised end-to-end through maybePerformAs(): the + * Result\Error must surface without 'de' having been uninstalled. + */ + public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $de = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true); + + $lng_objects = static fn(): array => [ + ['obj_id' => 1, 'title' => 'de'], + ['obj_id' => 2, 'title' => 'fr'], + ['obj_id' => 3, 'title' => 'fr'], + ]; + $obj_language_factory = static function (int $id) use ($de): FakeLanguageObject { + if ($id === 1) { + return $de; + } + + throw new \LogicException('must never be called for the ambiguous "fr" title'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]); + + $this->assertTrue($result->isError()); + $this->assertInstanceOf(AmbiguousLanguageTitleException::class, $result->error()); + $this->assertSame(0, $de->uninstallCallCount()); + } + + /** + * @param array $language_objects + * Keyed by language key; each entry becomes a fake object with + * the given isSystemLanguage()/isUserLanguage()/isInstalled() + * answers (default false/false/true) and uninstall() return + * value (default 'uninstalled') reachable from the + * corresponding obj_id (assigned in iteration order, starting at 1). + * @return array{0: array, 1: \Closure, 2: \Closure} + * [obj id => fake object, lng_objects closure, obj_language_factory closure] + */ + private function buildFakeLanguageWorld(array $language_objects): array + { + $lng_objects_list = []; + $fakes_by_obj_id = []; + $obj_id = 1; + + foreach ($language_objects as $language_key => $flags) { + $lng_objects_list[] = ['obj_id' => $obj_id, 'title' => $language_key]; + $fakes_by_obj_id[$obj_id] = new FakeLanguageObject( + is_system_language: $flags['system'] ?? false, + is_user_language: $flags['user'] ?? false, + is_installed: $flags['installed'] ?? true, + uninstall_return_value: $flags['uninstall_return'] ?? 'uninstalled', + ); + $obj_id++; + } + + $lng_objects = static fn(): array => $lng_objects_list; + $obj_language_factory = static fn(int $id): FakeLanguageObject => $fakes_by_obj_id[$id]; + + return [$fakes_by_obj_id, $lng_objects, $obj_language_factory]; + } + + public function testInstalledOrdinaryLanguageIsUninstalled(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame(['de'], $result['uninstalled_language_keys']); + $this->assertSame([], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + } + + public function testSystemLanguageIsNeverUninstalled(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['system' => true], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['uninstalled_language_keys']); + $this->assertSame(['de'], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(0, $fakes[1]->uninstallCallCount()); + } + + public function testLanguageCurrentlyInUseIsNeverUninstalled(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['user' => true], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['uninstalled_language_keys']); + $this->assertSame([], $result['system_language_keys']); + $this->assertSame(['de'], $result['user_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + $this->assertSame(0, $fakes[1]->uninstallCallCount()); + } + + /** + * isSystemLanguage() takes priority: a language that is somehow flagged + * as both the system language and the language currently in use must be + * reported as the system language, not as "in use" - the two branches + * are checked in that order in perform(). + */ + public function testSystemLanguageTakesPriorityOverUserLanguage(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['system' => true, 'user' => true], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame(['de'], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + } + + public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalled(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['installed' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['uninstalled_language_keys']); + $this->assertSame([], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + $this->assertSame(0, $fakes[1]->uninstallCallCount()); + } + + /** + * Regression test: uninstall() itself is the authority on whether the + * uninstallation actually happened, not merely the three outer guards + * (isSystemLanguage()/isUserLanguage()/isInstalled()). A language that + * passes all three guards (so perform() does call uninstall()) but + * whose uninstall() nonetheless returns '' (e.g. because a future, + * additional internal guard inside \ilObjLanguage::uninstall() itself + * rejects it) must be reported as not_installed_language_keys, never + * as uninstalled_language_keys - anything else would misreport a + * rejected uninstallation as a success. + */ + public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => ['uninstall_return' => ''], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['uninstalled_language_keys']); + $this->assertSame([], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + // uninstall() must still have been attempted - unlike the guarded + // cases (system/user/not-installed), where it is never called. + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + } + + /** + * A language key with no corresponding "lng" object at all must be + * reported as not installed - and, crucially, $obj_language_factory + * must never even be invoked for it (there is no obj_id to construct it + * from in the first place). + */ + public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingTheObjectFactory(): void + { + $lng_objects = static fn(): array => []; + $factory_calls = []; + $obj_language_factory = static function (int $id) use (&$factory_calls): FakeLanguageObject { + $factory_calls[] = $id; + throw new \LogicException('must never be called for an unknown language key'); + }; + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => 'xx', + ]); + + $this->assertSame([], $result['uninstalled_language_keys']); + $this->assertSame([], $result['system_language_keys']); + $this->assertSame([], $result['user_language_keys']); + $this->assertSame(['xx'], $result['not_installed_language_keys']); + $this->assertSame([], $factory_calls); + } + + /** + * A single request can carry languages in every possible state at once; + * each must land in its own bucket, and only the genuinely uninstalled + * one may ever call uninstall(). + */ + public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'en' => ['system' => true], + 'fr' => ['user' => true], + 'it' => ['installed' => false], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + // 'xx' has no backing object at all. + 'language_keys' => ['de', 'en', 'fr', 'it', 'xx'], + ]); + + $this->assertSame(['de'], $result['uninstalled_language_keys']); + $this->assertSame(['en'], $result['system_language_keys']); + $this->assertSame(['fr'], $result['user_language_keys']); + $this->assertSame(['it', 'xx'], $result['not_installed_language_keys']); + + $this->assertSame(1, $fakes[1]->uninstallCallCount(), 'de must be uninstalled'); + $this->assertSame(0, $fakes[2]->uninstallCallCount(), 'system language must never be uninstalled'); + $this->assertSame(0, $fakes[3]->uninstallCallCount(), 'in-use language must never be uninstalled'); + $this->assertSame(0, $fakes[4]->uninstallCallCount(), 'not-installed language must never be uninstalled'); + } + + public function testDuplicateLanguageKeysAcrossStringAndArrayAreDeduplicated(): void + { + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ + 'language_keys' => [' de, de ', 'de'], + ]); + + $this->assertSame(['de'], $result['uninstalled_language_keys']); + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + } + + public static function invalidLanguageKeysProvider(): array + { + return [ + 'missing language_keys key' => [[]], + 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], + 'nested array value' => [['language_keys' => ['de', ['fr']]]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + 'three letters' => [['language_keys' => 'deu']], + 'one letter' => [['language_keys' => 'd']], + 'uppercase' => [['language_keys' => 'DE']], + 'contains a digit' => [['language_keys' => 'de1']], + 'contains a hyphen' => [['language_keys' => 'de-at']], + ]; + } + + #[DataProvider('invalidLanguageKeysProvider')] + public function testInvalidLanguageKeysAreRejected(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see UninstallLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends - since perform() was switched + * from a plain \InvalidArgumentException to InvalidInputException for + * this check, matching every other Activity in this component. + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); + } + + public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField(): void + { + $text = $this->createMock(Text::class); + $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $text->expects($this->once()) + ->method('withDedicatedName') + ->with('language_keys') + ->willReturnSelf(); + + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->expects($this->once())->method('text')->with( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->willReturn($text); + // UninstallLanguage has no mode parameter - the select field must + // never be built. + $field->expects($this->never())->method('select'); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text]) + ->willReturn($group); + + $activity = $this->createActivity( + static fn(): array => [], + static fn(int $id) => null + ); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with(6, 'write', $this->anything()) + ->willReturn(false); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac, + $language + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + $this->assertSame(0, $fakes[1]->uninstallCallCount()); + } + + public function testPermissionGrantedPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + ]); + + $result = $this->createActivity( + $lng_objects, + $obj_language_factory, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertFalse($result->isError()); + $this->assertSame(['de'], $result->value()['uninstalled_language_keys']); + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind(), via + * collectRawValues()'s call to joinListOfStringsRawValue() (see that + * method's own docblock: "Returns $raw_value joined into a single + * comma-separated string if every one of its items is a string; + * otherwise returns $raw_value unchanged, in whatever shape the caller + * passed"), must join that array into the same shape a real HTML text + * input would carry BEFORE it reaches the declared Text field, rather + * than rejecting it. This is exercised through the REAL + * getInputDescription()/grind() pipeline (createRealFieldsUiFactory(), + * not a mocked FormInput) via maybePerformAs() - the previously + * blocking regression this test guards against. + */ + public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUninstallsEachOne(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ + 'de' => [], + 'fr' => [], + ]); + + $result = $this->createActivity($lng_objects, $obj_language_factory, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]); + + $this->assertFalse($result->isError()); + $this->assertSame(['de', 'fr'], $result->value()['uninstalled_language_keys']); + $this->assertSame(1, $fakes[1]->uninstallCallCount()); + $this->assertSame(1, $fakes[2]->uninstallCallCount()); + } + + private function createActivity( + \Closure $lng_objects, + \Closure $obj_language_factory, + ?\ilRbacSystem $rbac = null, + ?Language $language = null + ): UninstallLanguage { + return new UninstallLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + 0, + $lng_objects, + $obj_language_factory + ); + } +} + +/** + * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or + * meaningfully mocked in a unit test (its constructor requires the global + * $DIC), so tests substitute this fake via UninstallLanguage's + * $obj_language_factory closure instead. Counts uninstall() invocations so + * tests can assert it was never called for a bucket other than + * "uninstalled". + */ +final class FakeLanguageObject +{ + private int $uninstall_calls = 0; + + /** + * @param string $uninstall_return_value What uninstall() returns - a + * non-empty string mimics a real, successful uninstall; '' + * mimics \ilObjLanguage::uninstall() internally rejecting the + * call despite the three outer guards (isSystemLanguage()/ + * isUserLanguage()/isInstalled()) having let it through - e.g. a + * future additional guard added inside uninstall() itself. + */ + public function __construct( + private readonly bool $is_system_language, + private readonly bool $is_user_language, + private readonly bool $is_installed, + private readonly string $uninstall_return_value = 'uninstalled', + ) { + } + + public function isSystemLanguage(): bool + { + return $this->is_system_language; + } + + public function isUserLanguage(): bool + { + return $this->is_user_language; + } + + public function isInstalled(): bool + { + return $this->is_installed; + } + + public function uninstall(): string + { + $this->uninstall_calls++; + + return $this->uninstall_return_value; + } + + public function uninstallCallCount(): int + { + return $this->uninstall_calls; + } +} diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php new file mode 100644 index 000000000000..abed413ed7d6 --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -0,0 +1,409 @@ +createSetupLanguageMock([], [], ['de']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + + return $this->createActivity($setup_language); + } + + protected function validPerformParameters(): array + { + return ['language_keys' => 'de']; + } + + public function testSingleAlreadyInstalledLanguageIsRefreshed(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('registerInstalledLanguage')->with( + 'de', + [], + [] + ); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ' de ', + ]); + + $this->assertSame(['de'], $result['updated_language_keys']); + $this->assertSame([], $result['not_installed_language_keys']); + } + + /** + * A language that is not installed at all is a complete no-op - not even + * checkLanguageForInstallation may run for it, since there is nothing + * installed yet to refresh. Use InstallLanguage to install it first. + */ + public function testSingleNotInstalledLanguageIsCompleteNoOp(): void + { + $setup_language = $this->createSetupLanguageMock([], [], []); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + ]); + + $this->assertSame([], $result['updated_language_keys']); + $this->assertSame(['de'], $result['not_installed_language_keys']); + } + + /** + * A single request can carry both already-installed and not-installed + * languages at once; each must land in its own bucket, and only the + * installed one may trigger any write method. + */ + public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('registerInstalledLanguage')->with('de', [], []); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'fr'], + ]); + + $this->assertSame(['de'], $result['updated_language_keys']); + $this->assertSame(['fr'], $result['not_installed_language_keys']); + } + + /** + * getAvailableLanguagesForInstallation() (the $known_languages/$db_languages + * argument) and getLocalLanguages() ($local_language_keys) must be passed + * to registerInstalledLanguage() in exactly this order - both empty (or + * equal) values would make a swap of the two arguments invisible, so + * this test deliberately uses distinguishable shapes for each. + */ + public function testRegisterInstalledLanguageReceivesKnownLanguagesAndLocalLanguageKeysInTheCorrectOrder(): void + { + $setup_language = $this->createSetupLanguageMock( + ['en' => ['obj_id' => 1, 'status' => 'installed']], // $db_languages + ['fr'], // $local_language_keys + ['de'] // installed + ); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->expects($this->once()) + ->method('registerInstalledLanguage') + ->with( + 'de', + ['en' => ['obj_id' => 1, 'status' => 'installed']], + ['fr'] + ); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + ]); + } + + public function testInvalidLanguageFileOfAnAlreadyInstalledLanguagePreventsRefreshAndThrows(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(false); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid language files: de'); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + ]); + } + + /** + * All invalid keys among the ones actually being refreshed must be + * collected into a single error, not just the first one found - and the + * not-installed key must never even be validated, since it is a no-op. + */ + public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de', 'xx']); + $setup_language->expects($this->exactly(2)) + ->method('checkLanguageForInstallation') + ->willReturnMap([ + ['de', false], + ['xx', false], + ]); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid language files: de, xx'); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'xx', 'fr'], // 'fr' is not installed - untouched + ]); + } + + /** + * When every requested language turns out to be not installed (a + * complete no-op), perform() must not even read the available/local + * languages - there is nothing left to do that would need them. + */ + public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirely(): void + { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getInstalledLanguages')->willReturn([]); + $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); + $setup_language->expects($this->never())->method('getLocalLanguages'); + $setup_language->expects($this->never())->method('checkLanguageForInstallation'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $setup_language->expects($this->never())->method('insertLanguageForInstallation'); + $setup_language->expects($this->never())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => ['de', 'en'], + ]); + + $this->assertSame([], $result['updated_language_keys']); + $this->assertSame(['de', 'en'], $result['not_installed_language_keys']); + } + + /** + * UpdateLanguage never deals with customizing/local files as a distinct + * concept the way InstallLanguage does (mode "install_local") - it must + * never even look at whether a local file is invalid. + */ + public function testNeverInspectsInvalidLocalLanguageFiles(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + $setup_language->expects($this->never())->method('getInvalidLocalLanguageFiles'); + + $this->createActivity($setup_language)->perform([ + 'language_keys' => 'de', + ]); + } + + public function testDuplicateLanguageKeysAcrossStringAndArrayAreDeduplicated(): void + { + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->expects($this->once()) + ->method('checkLanguageForInstallation') + ->with('de') + ->willReturn(true); + $setup_language->expects($this->once())->method('flushLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('insertLanguageForInstallation')->with('de'); + $setup_language->expects($this->once())->method('registerInstalledLanguage'); + + $result = $this->createActivity($setup_language)->perform([ + 'language_keys' => [' de, de ', 'de'], + ]); + + $this->assertSame(['de'], $result['updated_language_keys']); + } + + public static function invalidLanguageKeysProvider(): array + { + return [ + 'missing language_keys key' => [[]], + 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], + 'nested array value' => [['language_keys' => ['de', ['fr']]]], + // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s + // format validation (exactly two lowercase ASCII letters). + 'three letters' => [['language_keys' => 'deu']], + 'one letter' => [['language_keys' => 'd']], + 'uppercase' => [['language_keys' => 'DE']], + 'contains a digit' => [['language_keys' => 'de1']], + 'contains a hyphen' => [['language_keys' => 'de-at']], + ]; + } + + #[DataProvider('invalidLanguageKeysProvider')] + public function testInvalidLanguageKeysAreRejected(array $parameters): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); + } + + /** + * Regression test for the perform()-parameter-type-check unification + * (see UpdateLanguage::perform()): a non-array $parameters must now + * raise the concrete InvalidInputException - not just the more general + * \InvalidArgumentException it extends. + */ + public function testNonArrayParametersAreRejected(): void + { + $this->expectException(InvalidInputException::class); + + $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); + } + + public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField(): void + { + $text = $this->createMock(Text::class); + $text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $text->expects($this->once()) + ->method('withDedicatedName') + ->with('language_keys') + ->willReturnSelf(); + + $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); + $field->expects($this->once())->method('text')->with( + 'Language keys', + 'Comma-separated list of language keys, e.g. de, fr, it.' + )->willReturn($text); + // UpdateLanguage has no mode parameter - the select field must never + // be built, unlike InstallLanguage's input description. + $field->expects($this->never())->method('select'); + + $group = $this->createMock(Group::class); + $field->expects($this->once()) + ->method('group') + ->with(['language_keys' => $text]) + ->willReturn($group); + + $activity = $this->createActivity( + $this->createSetupLanguageMock([], [], []) + ); + + $this->assertSame($group, $activity->getInputDescription($field)); + } + + public function testPermissionDeniedBeforePerform(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->expects($this->once()) + ->method('checkAccessOfUser') + ->with( + 6, + 'write', + $this->anything() + ) + ->willReturn(false); + + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->expects($this->never())->method('getInstalledLanguages'); + $setup_language->expects($this->never())->method('flushLanguageForInstallation'); + $language = $this->createMock(Language::class); + $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); + + $result = $this->createActivity( + $setup_language, + $rbac, + $language + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertTrue($result->isError()); + } + + public function testPermissionGrantedPerformsAndReturnsOkResult(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], ['de']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + + $result = $this->createActivity( + $setup_language, + $rbac + )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); + + $this->assertFalse($result->isError()); + $this->assertSame(['de'], $result->value()['updated_language_keys']); + } + + /** + * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) + * always builds 'language_keys' as a PHP array of strings, never a + * comma-separated string - and GrindsFormInput::grind() (via its + * private joinListOfStringsRawValue() helper - see that method's own + * docblock) must join that array into the same shape a real HTML text + * input would carry BEFORE + * it reaches the declared Text field, rather than rejecting it. This + * is exercised through the REAL getInputDescription()/grind() pipeline + * (createRealFieldsUiFactory(), not a mocked FormInput) via + * maybePerformAs() - the previously blocking regression this test + * guards against. + */ + public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne(): void + { + $rbac = $this->createMock(\ilRbacSystem::class); + $rbac->method('checkAccessOfUser')->willReturn(true); + + $setup_language = $this->createSetupLanguageMock([], [], ['de', 'fr']); + $setup_language->method('checkLanguageForInstallation')->willReturn(true); + + $result = $this->createActivity($setup_language, $rbac) + ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => ['de', 'fr']]); + + $this->assertFalse($result->isError()); + $this->assertSame(['de', 'fr'], $result->value()['updated_language_keys']); + } + + private function createActivity( + ilSetupLanguage $setup_language, + ?\ilRbacSystem $rbac = null, + ?Language $language = null + ): UpdateLanguage { + return new UpdateLanguage( + $this->createMock(RefineryFactory::class), + $language ?? $this->createMock(Language::class), + $rbac ?? $this->createMock(\ilRbacSystem::class), + $setup_language + ); + } + + private function createSetupLanguageMock( + array $available_languages, + array $local_language_keys, + array $installed_language_keys + ): MockObject&ilSetupLanguage { + $setup_language = $this->createMock(ilSetupLanguage::class); + $setup_language->method('getAvailableLanguagesForInstallation')->willReturn($available_languages); + $setup_language->method('getLocalLanguages')->willReturn($local_language_keys); + $setup_language->method('getInstalledLanguages')->willReturn($installed_language_keys); + + return $setup_language; + } +} diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php new file mode 100644 index 000000000000..c79b842026d1 --- /dev/null +++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php @@ -0,0 +1,98 @@ +component, which + * never changes for the object's lifetime. It is now cached in + * $this->base_directory on first access. This test does not attempt to + * observe the caching mechanism itself (e.g. by counting Reflection calls) - + * per the task, the point is a non-regression guard: for an unchanged + * object, repeated getPath() calls must keep returning the exact same + * value, and that value must still be the one the original (uncached) + * computation would have produced. + */ +class ComponentLanguageFileDirectoryTest extends TestCase +{ + public function testGetPathReturnsTheSameValueOnRepeatedCallsAndMatchesTheUncachedComputation(): void + { + // A real (non-mock) class defined right here, so + // ReflectionClass::getFileName() resolves to *this* test file's real + // path on disk - deterministic, and independently computable below + // without reusing ComponentLanguageFileDirectory's own internals. + $component = new class () implements Component { + public function init( + array|\ArrayAccess &$define, + array|\ArrayAccess &$implement, + array|\ArrayAccess &$use, + array|\ArrayAccess &$contribute, + array|\ArrayAccess &$seek, + array|\ArrayAccess &$provide, + array|\ArrayAccess &$pull, + array|\ArrayAccess &$internal, + ): void { + } + }; + + $directory = new ComponentLanguageFileDirectory($component, 'tst', 'lang/'); + + // Independently reproduce what getPath() is documented to compute, + // from __DIR__ of this test file rather than from the production + // class's internals. + $ilias_root = (string) realpath(__DIR__ . '/../../../../../'); + self::assertNotSame('', $ilias_root, 'Could not resolve the ILIAS root from this test file - did it move?'); + $expected_base_directory = str_replace($ilias_root . '/', '', __DIR__); + $expected_path = $expected_base_directory . '/lang/'; + + $first_call = $directory->getPath(); + $second_call = $directory->getPath(); + $third_call = $directory->getPath(); + + self::assertSame($expected_path, $first_call); + self::assertSame($first_call, $second_call); + self::assertSame($first_call, $third_call); + } + + public function testGetPathAppendsTheConfiguredPathInsideComponentToTheBaseDirectory(): void + { + $component = new class () implements Component { + public function init( + array|\ArrayAccess &$define, + array|\ArrayAccess &$implement, + array|\ArrayAccess &$use, + array|\ArrayAccess &$contribute, + array|\ArrayAccess &$seek, + array|\ArrayAccess &$provide, + array|\ArrayAccess &$pull, + array|\ArrayAccess &$internal, + ): void { + } + }; + + $directory = new ComponentLanguageFileDirectory($component, 'tst', 'custom/lang/path/'); + + self::assertStringEndsWith('/custom/lang/path/', $directory->getPath()); + } +} diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php new file mode 100644 index 000000000000..e907253fe424 --- /dev/null +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -0,0 +1,284 @@ + $this->createMock(\ILIAS\Language\Language::class), + ]; + $seek = [ + LanguageFileDirectory::class => [], + ]; + $pull = [ + RefineryFactory::class => $this->createMock(RefineryFactory::class), + ]; + + // $implement and $contribute are wrapped in RenamingDIC in the real + // generated bootstrap (see Renderer::renderComponent()) so that + // several assignments to the very same key don't collide - a plain + // Pimple\Container would silently let the second assignment + // overwrite the first. + $implement = new RenamingDIC(new PimpleContainer()); + $contribute = new RenamingDIC(new PimpleContainer()); + $provide = new PimpleContainer(); + $internal = new PimpleContainer(); + + (new LanguageComponent())->init( + $define, + $implement, + $use, + $contribute, + $seek, + $provide, + $pull, + $internal + ); + + return [$define, $implement, $use, $contribute, $seek, $provide, $pull, $internal]; + } + + public function testDefinesTheLanguageInterface(): void + { + [$define] = $this->initComponent(); + + self::assertSame([\ILIAS\Language\Language::class], $define); + } + + public function testProvideEntriesResolveToExpectedConcreteClasses(): void + { + [, , , , , $provide] = $this->initComponent(); + + self::assertInstanceOf(LanguageFileDirectoryManager::class, $provide[LanguageFileDirectoryManager::class]); + self::assertInstanceOf(InstalledLanguageDatabaseRepository::class, $provide[InstalledLanguageRepository::class]); + self::assertInstanceOf(LanguageInstallationManager::class, $provide[LanguageInstallationManager::class]); + self::assertInstanceOf(InstallLanguage::class, $provide[InstallLanguage::class]); + self::assertInstanceOf(UpdateLanguage::class, $provide[UpdateLanguage::class]); + self::assertInstanceOf(UninstallLanguage::class, $provide[UninstallLanguage::class]); + self::assertInstanceOf(RemoveLocalLanguageChanges::class, $provide[RemoveLocalLanguageChanges::class]); + self::assertInstanceOf(AddLanguageEntry::class, $provide[AddLanguageEntry::class]); + self::assertInstanceOf(SetLanguageDetectionEnabled::class, $provide[SetLanguageDetectionEnabled::class]); + self::assertInstanceOf(SetLanguageTranslationEnabled::class, $provide[SetLanguageTranslationEnabled::class]); + } + + public function testProvidedServicesShareTheSameInternalSingletonInstances(): void + { + [, , , , , $provide, , $internal] = $this->initComponent(); + + // $provide[...] is only a thin re-export of $internal[...]; both + // must resolve to the exact same object (Pimple caches the closure + // result), not merely to objects of the same class. + self::assertSame($internal[LanguageFileDirectoryManager::class], $provide[LanguageFileDirectoryManager::class]); + self::assertSame($internal[InstalledLanguageDatabaseRepository::class], $provide[InstalledLanguageRepository::class]); + self::assertSame($internal[LanguageInstallationManager::class], $provide[LanguageInstallationManager::class]); + self::assertSame($internal[InstallLanguage::class], $provide[InstallLanguage::class]); + self::assertSame($internal[UpdateLanguage::class], $provide[UpdateLanguage::class]); + self::assertSame($internal[UninstallLanguage::class], $provide[UninstallLanguage::class]); + self::assertSame($internal[RemoveLocalLanguageChanges::class], $provide[RemoveLocalLanguageChanges::class]); + self::assertSame($internal[AddLanguageEntry::class], $provide[AddLanguageEntry::class]); + self::assertSame( + $internal[SetLanguageDetectionEnabled::class], + $provide[SetLanguageDetectionEnabled::class] + ); + self::assertSame( + $internal[SetLanguageTranslationEnabled::class], + $provide[SetLanguageTranslationEnabled::class] + ); + } + + public function testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder(): void + { + [, , , $contribute] = $this->initComponent(); + + // RenamingDIC assigns a single, container-wide "_" suffix + // to every offsetSet call, in call order. Reading these exact keys + // back pins the relative order of the ten $contribute entries, + // which the reorganisation was explicitly required to preserve - + // including the seven \ILIAS\Component\Activities\Activity::class + // entries (InstallLanguage, then UpdateLanguage, then + // UninstallLanguage, then RemoveLocalLanguageChanges, then + // AddLanguageEntry, then SetLanguageDetectionEnabled, then + // SetLanguageTranslationEnabled), one per Activity this component + // offers. The UserSettings contribution's counter shifted from _4 + // to _5 when UninstallLanguage's own Activity contribution was + // added ahead of it, then from _5 to _6 when + // RemoveLocalLanguageChanges' Activity contribution was added ahead + // of it in turn, then from _6 to _7 with AddLanguageEntry's Activity + // contribution added ahead of it, then from _7 to _8 with + // SetLanguageDetectionEnabled's Activity contribution added ahead of + // it, and has now shifted again from _8 to _9 with + // SetLanguageTranslationEnabled's Activity contribution added ahead + // of it - a hardcoded '_8' for UserSettingsSettings would otherwise + // silently start resolving to the wrong contribute entry (or, as + // happened before, to no entry at all). + self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); + self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); + self::assertInstanceOf(UpdateLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_3']); + self::assertInstanceOf(UninstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_4']); + self::assertInstanceOf(RemoveLocalLanguageChanges::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_5']); + self::assertInstanceOf(AddLanguageEntry::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_6']); + self::assertInstanceOf(SetLanguageDetectionEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_7']); + self::assertInstanceOf(SetLanguageTranslationEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_8']); + self::assertInstanceOf(UserSettingsSettings::class, $contribute[\ILIAS\User\Settings\UserSettings::class . '_9']); + } + + public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstances(): void + { + [, , , $contribute, , $provide] = $this->initComponent(); + + // ilLanguageSetupAgent (Setup path) and ilObjLanguageFolderGUI + // (via $provide[InstallLanguage::class]) must operate on the exact + // same InstallLanguage instance, not merely equal ones - and the + // same holds for UpdateLanguage, UninstallLanguage, + // RemoveLocalLanguageChanges, AddLanguageEntry, + // SetLanguageDetectionEnabled and SetLanguageTranslationEnabled. + self::assertSame( + $provide[InstallLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] + ); + self::assertSame( + $provide[UpdateLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_3'] + ); + self::assertSame( + $provide[UninstallLanguage::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_4'] + ); + self::assertSame( + $provide[RemoveLocalLanguageChanges::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_5'] + ); + self::assertSame( + $provide[AddLanguageEntry::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_6'] + ); + self::assertSame( + $provide[SetLanguageDetectionEnabled::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_7'] + ); + self::assertSame( + $provide[SetLanguageTranslationEnabled::class], + $contribute[\ILIAS\Component\Activities\Activity::class . '_8'] + ); + } + + public function testImplementCandidatesAreOfferedInDeclarationOrder(): void + { + [, $implement] = $this->initComponent(); + + // The Setup-path candidate (\ilSetupLanguage) must be registered + // before the runtime/Init-path candidate + // (LanguageLegacyInitialisationAdapter) - consumers disambiguate + // by position (see cli/build_bootstrap.php / + // Renderer::renderUse()), so a swap here would silently flip which + // candidate every $use[\ILIAS\Language\Language::class] consumer + // gets wired to. + self::assertInstanceOf(\ilSetupLanguage::class, $implement[\ILIAS\Language\Language::class . '_0']); + self::assertInstanceOf( + LanguageLegacyInitialisationAdapter::class, + $implement[\ILIAS\Language\Language::class . '_1'] + ); + } + + /** + * Regression guard for the $ilias_root / $resolve_db deduplication: + * both InstalledLanguageDatabaseRepository and LanguageInstallationManager + * must still end up with the exact same, correctly computed ILIAS root + * path (previously two textually-identical `realpath()` calls, now one + * shared local variable read twice). + */ + public function testDeduplicatedIliasRootIsCorrectAndIdenticalForBothConsumers(): void + { + [, , , , , $provide] = $this->initComponent(); + + $expected_root = (string) realpath(__DIR__ . '/../../../../'); + + self::assertNotSame('', $expected_root, 'sanity check: the ILIAS root must resolve to a real path'); + + $repository_root = self::readPrivateProperty($provide[InstalledLanguageRepository::class], 'absolute_path'); + $manager_root = self::readPrivateProperty($provide[LanguageInstallationManager::class], 'absolute_path'); + + self::assertSame($expected_root, $repository_root); + self::assertSame($expected_root, $manager_root); + } + + private static function readPrivateProperty(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } +} diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php new file mode 100644 index 000000000000..f669fddf5e2f --- /dev/null +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -0,0 +1,276 @@ +createMock(\ilLanguage::class); + $lng->method('txt')->willReturnArgument(0); + + return $lng; + } + + public function testAPlainStringErrorIsReturnedAsIsAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage('msg_no_perm_write'); + + $this->assertSame('msg_no_perm_write', $message); + } + + public function testASafeToDisplayActivityErrorMessageIsReturnedAsIsAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new InvalidInputException('language_keys: not_min_length'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame('language_keys: not_min_length', $message); + } + + public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDisplayAndNeverLogged(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + // The domain layer (UninstallLanguage/RemoveLocalLanguageChanges etc.) + // deliberately embeds the raw, unescaped title in the exception + // message - activityErrorMessage() is the one seam that HTML-escapes + // it for safe display, so the '"' characters must come back as + // '"' here. + $this->assertSame('Multiple language objects share the title(s) "fr".', $message); + } + + /** + * Regression test for the escaping seam itself: a SafeToDisplayActivityError + * message containing genuinely dangerous markup (not just a stray quote) + * must come back HTML-escaped - proving activityErrorMessage() is not + * merely quote-escaping by coincidence but actually protects against + * XSS-shaped content that a caller-controlled language key/title/field + * name could carry. + */ + public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void + { + $logger = $this->createMock(\ilLogger::class); + $logger->expects($this->never())->method('error'); + + $error = new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'); + + $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) + ->callActivityErrorMessage($error); + + $this->assertSame( + 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', + $message + ); + $this->assertStringNotContainsString('', $error->getMessage()); } @@ -268,10 +244,8 @@ public static function nonArrayNonNullGroupRawValueProvider(): array } /** - * A group's raw value that is neither null (omitted) nor an array is a genuine type mismatch - * and must be rejected - silently degrading it to an empty array would instead make every - * child field look merely "omitted", masking the real problem behind a misleading - * required-field error. + * A group value that is neither null nor an array must be rejected; degrading it to an empty array would mask + * it as a misleading required-field error. */ #[DataProvider('nonArrayNonNullGroupRawValueProvider')] public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray( diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php index 7bdd3dc7ca12..8a60ae15a3d8 100644 --- a/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTestHost.php @@ -25,9 +25,7 @@ use ILIAS\UI\Component\Input\Container\Form\FormInput; /** - * Minimal host for GrindsFormInput: only mixes in the trait and exposes its - * private grind() through a public wrapper, so the trait's own contract can - * be tested in isolation from any concrete Activity - see GrindsFormInputTest. + * Exposes the private grind() of GrindsFormInput through a public wrapper. */ final class GrindsFormInputTestHost { diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index 7fc2bd8da82c..62819ad8a63c 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -530,8 +530,7 @@ public static function invalidLanguageKeysProvider(): array 'nested array value' => [ ['language_keys' => ['de', ['fr']], 'mode' => InstallLanguage::MODE_INSTALL], ], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). + // format validation of ParsesLanguageKeyList::toLanguageKeyList() 'three letters' => [ ['language_keys' => 'deu', 'mode' => InstallLanguage::MODE_INSTALL], ], @@ -651,10 +650,7 @@ public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInAR $rbac->method('checkAccessOfUser')->willReturn(true); $setup_language = $this->createSetupLanguageMock([], [], []); - // checkLanguageForInstallation() is called from within perform()'s - // own loop, well after isAllowedToPerform() succeeded - a realistic - // place for an unexpected \TypeError to surface (e.g. a - // misconfigured/incompatible collaborator), simulated here directly. + // raised inside perform()'s loop, after isAllowedToPerform() succeeded $setup_language->method('checkLanguageForInstallation')->willReturnCallback( static function (): bool { throw new \TypeError('simulated TypeError, not an \Exception'); diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php index 15b84b773a35..376d8d4c5055 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -16,7 +16,6 @@ * *********************************************************************/ - declare(strict_types=1); namespace ILIAS\Language\Tests\Activities; @@ -27,8 +26,7 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * The RBAC-free Setup variants and the lazy RBAC access of - * LanguageActivity. + * The RBAC-free Setup variants and the lazy RBAC access of LanguageActivity. */ class LanguageActivitySetupVariantTest extends \ilLanguageBaseTestCase { diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index f2a68796e232..4fc3897a4334 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -32,18 +32,15 @@ use ilLanguageBaseTestCase; /** - * LanguageActivity is abstract, exercised indirectly by every concrete Activity's own test suite; - * this file targets LanguageActivity::maybePerformAs() directly via minimal throwing test doubles, - * for behaviour no concrete Activity can trigger without an artificial collaborator failure. + * Targets LanguageActivity::maybePerformAs() directly via throwing test doubles, for behaviour no concrete + * Activity can trigger without an artificial collaborator failure. */ class LanguageActivityTest extends ilLanguageBaseTestCase { use RealFieldsUiFactory; /** - * A \Throwable raised while BUILDING the input description (not grinding, not perform()) must - * still come back as a Result\Error, per Activity::maybePerformAs()'s contract - rbac must - * never be reached either, since the failure happens before permission-checking. + * A \Throwable while building the input description must come back as a Result\Error, without reaching rbac. */ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResultErrorNotPropagated(): void { @@ -66,9 +63,8 @@ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResult } /** - * Same scenario, but the \Throwable is not an \Exception (a \TypeError) - `new - * Result\Error($e)` only accepts string|\Exception, so it must arrive wrapped in a - * \RuntimeException instead of raising an uncaught \InvalidArgumentException. + * A non-\Exception \Throwable (\TypeError) must arrive wrapped in a \RuntimeException, since Result\Error + * accepts only string|\Exception. */ public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void { @@ -94,11 +90,8 @@ public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntim } /** - * Regression test for maybePerformAs()'s array_merge() precedence: additionalPerformParameters() - * must win over a same-named key from normalizeParameters(). Uses a dedicated LanguageActivity - * double whose normalizeParameters() always returns a colliding 'usr_id', since - * AddLanguageEntry's own getInputDescription() never collects a 'usr_id' field and so never - * actually reaches this collision. + * additionalPerformParameters() must win over a same-named key from normalizeParameters() in maybePerformAs()'s + * array_merge(). Needs a dedicated double, as AddLanguageEntry never collects a 'usr_id' field. */ public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): void { @@ -116,24 +109,15 @@ public function testAdditionalPerformParametersWinsOverANormalizeParametersKeyCo $this->assertFalse($result->isError()); $parameters = $result->value(); - // The trusted, server-resolved usr_id (42, from maybePerformAs()'s own - // argument via additionalPerformParameters()) must win over the - // spoofed value normalizeParameters() produced from (simulated) form - // input. + // the trusted usr_id (42) wins over the spoofed one from normalizeParameters() $this->assertSame(42, $parameters['usr_id']); - // A non-colliding key from normalizeParameters() must still survive - // the merge untouched. + // a non-colliding key survives the merge $this->assertSame('form', $parameters['source']); } } /** - * Minimal concrete LanguageActivity whose getInputDescription() always - * throws, used exclusively to exercise LanguageActivity::maybePerformAs()'s - * generic try/catch around getInputDescription()/$input_factory->field() in - * isolation - every other abstract method is unreachable from - * maybePerformAs() once getInputDescription() throws, and simply throws - * itself if a test ever calls it by mistake. + * LanguageActivity whose getInputDescription() always throws; every other abstract method throws too. */ final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { @@ -178,11 +162,8 @@ protected function normalizeParameters(array $grind_result): array } /** - * Minimal LanguageActivity used only by - * testAdditionalPerformParametersWinsOverANormalizeParametersKeyCollisionInMaybePerformAs(): - * normalizeParameters() always returns a colliding 'usr_id', simulating spoofed form input. - * getInputDescription() returns an empty group so grind() trivially succeeds; perform() returns - * $parameters unchanged so the test can inspect what survived the merge. + * normalizeParameters() always returns a colliding 'usr_id' (spoofed form input); perform() returns $parameters + * unchanged so the test can inspect the merge. */ final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity { @@ -211,8 +192,7 @@ public function perform(mixed $parameters): mixed */ protected function normalizeParameters(array $grind_result): array { - // Simulates a spoofed 'usr_id' smuggled in through form input - must - // never win over additionalPerformParameters()'s trusted value below. + // spoofed 'usr_id' that must lose against additionalPerformParameters() return ['usr_id' => 'SPOOFED-VALUE', 'source' => 'form']; } diff --git a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php index 589043c09d58..fba29a700c6a 100644 --- a/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php +++ b/components/ILIAS/Language/tests/Activities/RealFieldsUiFactory.php @@ -29,43 +29,15 @@ use ILIAS\UI\Implementation\Component\SignalGenerator; /** - * Reusable helper mixed into every Activities test case that needs a - * `\ILIAS\UI\Component\Input\Factory` (reached via `->input()` below) whose - * `->field()->text()/checkbox()/select()/group()` return REAL, functioning - * UI framework Field objects - i.e. one that GrindsFormInput::grind() (see - * the trait's own docblock) can actually run - * `withNameFrom()`/`withInput()`/`getContent()` against, instead of a plain - * PHPUnit mock, which would either return null or a broken mock object for - * these calls (and grind()'s own try/catch would silently turn every such - * call into a Result\Error). - * - * `maybePerformAs()` (see LanguageActivity/every concrete Activity) only - * ever needs `\ILIAS\UI\Component\Input\Factory` - it calls - * `$input_factory->field()` directly and never touches any other - * `\ILIAS\UI\Factory` method - so every call site here only ever chains - * `->input()` off the returned factory before using it. The helper still - * returns the full, enclosing `\ILIAS\UI\Factory` (rather than the narrower - * `\ILIAS\UI\Component\Input\Factory` directly) purely because that is the - * natural return type of the mocked `->input()` accessor below; only - * `\ILIAS\UI\Factory::input()` and `\ILIAS\UI\Component\Input\Factory::field()` - * are ever called by these tests, so those two collaborators are plain - * PHPUnit mocks stubbed to return the real, concrete field factory below - - * see CommonFieldRendering::getFieldFactory() in - * components/ILIAS/UI/tests/Component/Input/Field/CommonFieldRendering.php - * for the same, established recipe. + * Gives Activity tests a UI factory whose `->input()->field()` returns REAL field objects, so that + * GrindsFormInput::grind() can run withNameFrom()/withInput()/getContent() on them; a plain mock would turn + * every call into a Result\Error. Same recipe as CommonFieldRendering::getFieldFactory() in the UI tests. */ trait RealFieldsUiFactory { /** - * Builds a `\ILIAS\UI\Factory` mock whose `input()` is a plain mock - * returning a REAL `\ILIAS\UI\Implementation\Component\Input\Field\Factory` - * from `field()` (the only two calls any Activity's `maybePerformAs()`/ - * `getInputDescription()` ever makes on it), wired against a real - * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory` pair and a - * `\ILIAS\Language\Language` mock whose `txt()` simply echoes back the - * key it was given. Callers invariably chain `->input()` on the result - * to obtain the `\ILIAS\UI\Component\Input\Factory` that `maybePerformAs()` - * actually declares as its parameter type. + * `input()` is a mock returning the real field factory from `field()`, wired against real + * `\ILIAS\Data\Factory`/`\ILIAS\Refinery\Factory`; `txt()` of the Language mock echoes the key. */ protected function createRealFieldsUiFactory(): \ILIAS\UI\Factory { diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index febb726e5152..84137def9653 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -425,7 +425,6 @@ public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInv $this->assertSame([], $result['removed_local_changes_language_keys']); $this->assertSame(['de'], $result['invalid_language_file_keys']); $this->assertSame([], $result['not_installed_language_keys']); - // removeLocalChanges() must still have been attempted. $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } @@ -470,7 +469,6 @@ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): voi ]); $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ - // 'xx' has no backing object at all. 'language_keys' => ['de', 'fr', 'it', 'xx'], ]); @@ -519,8 +517,6 @@ public static function invalidLanguageKeysProvider(): array 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], 'non-array, non-string value' => [['language_keys' => 42]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -661,9 +657,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $lng_objects = static fn(): array => []; $obj_language_factory = static fn(int $id) => null; - // An empty language_keys value normalizes to [] and throws inside - // perform() (via toLanguageKeyList()) - reachable only after the - // permission check has already passed, exercising the catch block. + // an empty language_keys value throws inside perform(), after the permission check passed $result = $this->createActivity( $lng_objects, $obj_language_factory, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index edb0e18f3217..09b154b43a26 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -29,20 +29,9 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * SetLanguageDetectionEnabled has no ilSetupLanguage collaborator and no - * $lng_objects/$obj_language_factory closures (unlike UninstallLanguage/ - * RemoveLocalLanguageChanges) - instead it wraps a single - * \ILIAS\Administration\Setting collaborator ("lang_detection") which is - * a plain interface and can therefore be mocked directly, unlike - * \ilObjLanguage on the sibling Activities. - * - * The most important regression this file guards is the *new* enforced - * "write" RBAC check in isAllowedToPerform()/maybePerformAs(): before this - * extraction, neither enableLanguageDetectionObject() nor - * disableLanguageDetectionObject() performed any permission check of their - * own. A test here must prove that a denied permission both yields a - * Result\Error *and* leaves the Setting collaborator untouched (no side - * effect on denial). + * SetLanguageDetectionEnabled wraps a single \ILIAS\Administration\Setting ("lang_detection"), which is a plain + * interface and can be mocked directly, unlike \ilObjLanguage on the sibling Activities. A denied permission must + * yield a Result\Error and leave the Setting untouched. */ class SetLanguageDetectionEnabledTest extends ActivityContractTestCase { @@ -51,10 +40,6 @@ protected function createDefaultActivity(): SetLanguageDetectionEnabled return $this->createActivity(); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -97,10 +82,6 @@ public function testIsAllowedToPerformNeverTouchesSettings(): void $this->assertTrue($activity->isAllowedToPerform(6, ['enabled' => true])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testPerformWithEnabledTrueWritesOneIntoLangDetectionSettingAndReturnsEnabledTrue(): void { $settings = $this->createMock(Setting::class); @@ -178,10 +159,6 @@ public static function nonBooleanEnabledValuesProvider(): array ]; } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -248,10 +225,7 @@ public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoARes ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => 'not-a-bool']); $this->assertTrue($result->isError()); - // 'not-a-bool' is outside normalizeCheckboxRawValue()'s explicit - // whitelist - grind()'s dedicated catch block converts the - // resulting \InvalidArgumentException into an InvalidInputException - // (a SafeToDisplayActivityError), not a raw \InvalidArgumentException. + // outside the whitelist of normalizeCheckboxRawValue(): grind() turns it into an InvalidInputException $this->assertInstanceOf(InvalidInputException::class, $result->error()); $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); } @@ -328,8 +302,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsA } /** - * Same tolerance as above, but for the common primitive representations - * of "false". + * Same tolerance for the common representations of "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void @@ -356,9 +329,7 @@ public static function rejectedEnabledValuesProvider(): array } /** - * Only values outside GrindsFormInput::normalizeCheckboxRawValue()'s - * explicit whitelist are still rejected via maybePerformAs() - "konservativ - * normalisieren", not "alles akzeptieren". + * Values outside the whitelist of GrindsFormInput::normalizeCheckboxRawValue() are still rejected. */ #[DataProvider('rejectedEnabledValuesProvider')] public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWithoutTouchingSettings(mixed $value): void @@ -377,10 +348,6 @@ public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWi $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); } - // ----------------------------------------------------------------- - // getInputDescription() / getOutputDescription() - // ----------------------------------------------------------------- - public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void { $checkbox = $this->createMock(Checkbox::class); @@ -424,10 +391,6 @@ public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void $this->assertSame($object_description, $this->createActivity()->getOutputDescription($f)); } - // ----------------------------------------------------------------- - // helpers - // ----------------------------------------------------------------- - private function createActivity( ?\ilRbacSystem $rbac_system = null, ?Setting $settings = null, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index c52378709ebd..261090a99157 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -32,25 +32,10 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * SetLanguageTranslationEnabled is, like its sibling - * SetLanguageDetectionEnabled, backed by a single \ILIAS\Administration\Setting - * collaborator - a plain interface, mockable directly, never a real - * \ilSetting/database connection. Unlike SetLanguageDetectionEnabled, this - * Activity is parameterised by a language_key (the setting written/read is - * "lang_translate_", not a single fixed key), and perform() itself - * (not just normalizeParameters()) validates its $parameters array and - * computes a `changed` flag by comparing the requested value against the - * *currently stored* one (read via a falsy (bool) cast, exactly mirroring - * `ilObjLanguageAccess::_checkTranslate()` - see `$currently_enabled = (bool) - * ($this->settings)()->get($translate_key, '0')` in perform()). That - * comparison, and the "write only if changed" behaviour it drives, is the - * most important regression surface in this file: a mutant that always - * writes (or that inverts the comparison) must be caught here. - * - * The second most important regression this file guards is the same one as - * SetLanguageDetectionEnabledTest: the enforced "write" RBAC check in - * isAllowedToPerform()/maybePerformAs() must actually prevent both the - * outcome *and* the side effect (no write to Settings) when denied. + * Backed by a single mockable \ILIAS\Administration\Setting. Unlike SetLanguageDetectionEnabled it is parameterised by + * a language_key ("lang_translate_"), and perform() itself validates $parameters and writes only if the value + * changed, comparing against the stored one with a falsy (bool) cast like ilObjLanguageAccess::_checkTranslate(). + * A denied "write" permission must prevent both the outcome and the Settings access. */ class SetLanguageTranslationEnabledTest extends ActivityContractTestCase { @@ -59,10 +44,6 @@ protected function createDefaultActivity(): SetLanguageTranslationEnabled return $this->createActivity(); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -106,10 +87,6 @@ public function testIsAllowedToPerformNeverTouchesSettings(): void $this->assertTrue($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); } - // ----------------------------------------------------------------- - // perform() - the changed/unchanged decision - // ----------------------------------------------------------------- - /** * Core business rule: perform() must read the * *current* value of "lang_translate_" via a falsy check - exactly @@ -196,10 +173,6 @@ public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedVal $this->assertSame('de', $result['language_key']); } - // ----------------------------------------------------------------- - // perform() - parameter validation - // ----------------------------------------------------------------- - public static function invalidPerformParametersProvider(): array { return [ @@ -233,10 +206,6 @@ public function testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSe $this->createActivity(settings: $settings)->perform($parameters); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResult(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -334,10 +303,6 @@ public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndN public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array { return [ - // The required 'language_key' Text field receives a blank raw - // value (missing entirely) and fails its own required-field - // constraint inside grind() itself - before normalizeParameters()/ - // isAllowedToPerform() are ever reached. 'missing language_key' => [['enabled' => true]], ]; } @@ -364,15 +329,9 @@ public function testMaybePerformAsRejectsRawParametersAtGrindLevelWithStringErro public static function invalidRawParametersProvider(): array { return [ - // A non-string 'language_key' fails the Text field's own - // isClientSideValueOk() check inside grind() itself, which - // throws an InvalidArgumentException ("Display value does not - // match input type."), caught and preserved by grind()'s - // try/catch. + // a non-string 'language_key' fails the Text field's isClientSideValueOk() inside grind() 'non-string language_key' => [['language_key' => 42, 'enabled' => true]], - // Outside GrindsFormInput::normalizeCheckboxRawValue()'s - // explicit whitelist - "konservativ normalisieren", not "alles - // akzeptieren". + // outside the whitelist of GrindsFormInput::normalizeCheckboxRawValue() 'non-bool enabled: array' => [['language_key' => 'de', 'enabled' => [true]]], 'non-bool enabled: float 1.0' => [['language_key' => 'de', 'enabled' => 1.0]], ]; @@ -499,8 +458,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent } /** - * Same tolerance as above, but for the common primitive representations - * of "false". + * Same tolerance for the common representations of "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void @@ -522,10 +480,6 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta ); } - // ----------------------------------------------------------------- - // language_key must be an installed language (Punkt 4) - // ----------------------------------------------------------------- - /** * Regression test for Punkt 4 of this component's grinding task: unlike * before, an unknown/not-installed language_key is now rejected by @@ -621,10 +575,6 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $this->assertSame('de', $result->value()['language_key']); } - // ----------------------------------------------------------------- - // getInputDescription() / getOutputDescription() / getDescription() - // ----------------------------------------------------------------- - public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFieldAndAnEnabledCheckbox(): void { $text = $this->createMock(Text::class); @@ -693,10 +643,6 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): $this->assertSame(['language_key', 'enabled', 'changed'], $field_names); } - // ----------------------------------------------------------------- - // helpers - // ----------------------------------------------------------------- - private function createActivity( ?\ilRbacSystem $rbac_system = null, ?Setting $settings = null, diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index d5e0ddaac42c..735f784ece14 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -451,8 +451,7 @@ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled( $this->assertSame([], $result['system_language_keys']); $this->assertSame([], $result['user_language_keys']); $this->assertSame(['de'], $result['not_installed_language_keys']); - // uninstall() must still have been attempted - unlike the guarded - // cases (system/user/not-installed), where it is never called. + // uninstall() must still have been attempted $this->assertSame(1, $fakes[1]->uninstallCallCount()); } @@ -483,9 +482,7 @@ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingThe } /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and only the genuinely uninstalled - * one may ever call uninstall(). + * Languages in every state at once land in their own bucket; only the uninstalled one calls uninstall(). */ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void { @@ -497,7 +494,6 @@ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): voi ]); $result = $this->createActivity($lng_objects, $obj_language_factory)->perform([ - // 'xx' has no backing object at all. 'language_keys' => ['de', 'en', 'fr', 'it', 'xx'], ]); @@ -532,8 +528,6 @@ public static function invalidLanguageKeysProvider(): array 'missing language_keys key' => [[]], 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -579,8 +573,7 @@ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField( 'Language keys', 'Comma-separated list of language keys, e.g. de, fr, it.' )->willReturn($text); - // UninstallLanguage has no mode parameter - the select field must - // never be built. + // no mode parameter: the select field must never be built $field->expects($this->never())->method('select'); $group = $this->createMock(Group::class); diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index e871f6b79fec..a2ad6b96c6f6 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -66,9 +66,8 @@ public function testSingleAlreadyInstalledLanguageIsRefreshed(): void } /** - * A language that is not installed at all is a complete no-op - not even - * checkLanguageForInstallation may run for it, since there is nothing - * installed yet to refresh. Use InstallLanguage to install it first. + * A language that is not installed is a complete no-op, not even checkLanguageForInstallation may run; + * InstallLanguage installs it. */ public function testSingleNotInstalledLanguageIsCompleteNoOp(): void { @@ -87,9 +86,7 @@ public function testSingleNotInstalledLanguageIsCompleteNoOp(): void } /** - * A single request can carry both already-installed and not-installed - * languages at once; each must land in its own bucket, and only the - * installed one may trigger any write method. + * Installed and not installed languages at once land in their own bucket; only the installed one triggers a write. */ public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void { @@ -158,9 +155,7 @@ public function testInvalidLanguageFileOfAnAlreadyInstalledLanguagePreventsRefre } /** - * All invalid keys among the ones actually being refreshed must be - * collected into a single error, not just the first one found - and the - * not-installed key must never even be validated, since it is a no-op. + * All invalid keys among the refreshed ones are collected into one error; the not-installed key is never validated. */ public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void { @@ -184,9 +179,7 @@ public function testMultipleInvalidLanguageFilesAreAllReportedTogether(): void } /** - * When every requested language turns out to be not installed (a - * complete no-op), perform() must not even read the available/local - * languages - there is nothing left to do that would need them. + * If every requested language is not installed, perform() must not read the available/local languages. */ public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirely(): void { @@ -208,9 +201,7 @@ public function testAllRequestedLanguagesNotInstalledSkipsLanguageLookupsEntirel } /** - * UpdateLanguage never deals with customizing/local files as a distinct - * concept the way InstallLanguage does (mode "install_local") - it must - * never even look at whether a local file is invalid. + * UpdateLanguage must never look at whether a local file is invalid (InstallLanguage mode "install_local" does). */ public function testNeverInspectsInvalidLocalLanguageFiles(): void { @@ -247,8 +238,6 @@ public static function invalidLanguageKeysProvider(): array 'missing language_keys key' => [[]], 'empty (only whitespace/commas)' => [['language_keys' => ' , ']], 'nested array value' => [['language_keys' => ['de', ['fr']]]], - // Regression coverage for ParsesLanguageKeyList::toLanguageKeyList()'s - // format validation (exactly two lowercase ASCII letters). 'three letters' => [['language_keys' => 'deu']], 'one letter' => [['language_keys' => 'd']], 'uppercase' => [['language_keys' => 'DE']], @@ -292,8 +281,7 @@ public function testInputDescriptionUsesOnlyTheLanguageKeysFieldWithNoModeField( 'Language keys', 'Comma-separated list of language keys, e.g. de, fr, it.' )->willReturn($text); - // UpdateLanguage has no mode parameter - the select field must never - // be built, unlike InstallLanguage's input description. + // no mode parameter: the select field must never be built $field->expects($this->never())->method('select'); $group = $this->createMock(Group::class); diff --git a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php index c79b842026d1..74162e12c26b 100644 --- a/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php +++ b/components/ILIAS/Language/tests/ComponentTranslation/ComponentLanguageFileDirectoryTest.php @@ -24,24 +24,15 @@ use PHPUnit\Framework\TestCase; /** - * getPath() used to recompute its ReflectionClass/realpath lookup on every - * call even though the result only ever depends on $this->component, which - * never changes for the object's lifetime. It is now cached in - * $this->base_directory on first access. This test does not attempt to - * observe the caching mechanism itself (e.g. by counting Reflection calls) - - * per the task, the point is a non-regression guard: for an unchanged - * object, repeated getPath() calls must keep returning the exact same - * value, and that value must still be the one the original (uncached) - * computation would have produced. + * Non-regression guard for the base directory getPath() caches: repeated calls on an unchanged object return the same + * value, and it is the one an uncached computation would produce. The caching itself is not observed. */ class ComponentLanguageFileDirectoryTest extends TestCase { public function testGetPathReturnsTheSameValueOnRepeatedCallsAndMatchesTheUncachedComputation(): void { - // A real (non-mock) class defined right here, so - // ReflectionClass::getFileName() resolves to *this* test file's real - // path on disk - deterministic, and independently computable below - // without reusing ComponentLanguageFileDirectory's own internals. + // a real (non-mock) class defined here, so ReflectionClass::getFileName() resolves to this file - deterministic + // and computable below without ComponentLanguageFileDirectory's internals $component = new class () implements Component { public function init( array|\ArrayAccess &$define, @@ -58,9 +49,7 @@ public function init( $directory = new ComponentLanguageFileDirectory($component, 'tst', 'lang/'); - // Independently reproduce what getPath() is documented to compute, - // from __DIR__ of this test file rather than from the production - // class's internals. + // independently reproduces getPath()'s computation from __DIR__ of this file $ilias_root = (string) realpath(__DIR__ . '/../../../../../'); self::assertNotSame('', $ilias_root, 'Could not resolve the ILIAS root from this test file - did it move?'); $expected_base_directory = str_replace($ilias_root . '/', '', __DIR__); diff --git a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php index 3f89e909ebe6..09e40e3c49ad 100644 --- a/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentDependenciesTest.php @@ -31,9 +31,7 @@ */ class LanguageComponentDependenciesTest extends TestCase { - /** - * @return list - */ + /** @return list */ private function names(InType $type): array { $result = (new Reader(new EvalLightMockBuilder()))->read(new ILIAS\Language()); diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index a07bb4ea6c15..14da7e83a905 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -43,33 +43,10 @@ use PHPUnit\Framework\TestCase; /** - * Regression coverage for the wiring performed by - * components/ILIAS/Language/Language.php::init(). + * Wiring of Language.php::init() through container types that mirror the generated bootstrap (Pimple\Container, + * RenamingDIC for $implement/$contribute), so regressions that only show with a real lazily-evaluated container are caught. * - * Language.php was reorganised so that $internal entries are declared in - * dependency order. - * Nothing about *which* class ends up behind which $implement / $contribute - * / $provide key was meant to change - the container types involved - * (Pimple\Container, wrapped in RenamingDIC for $implement/$contribute) are - * exactly what makes the reordering safe: closures created early still see - * later mutations of the same container object, because a PHP arrow - * function captures the *variable* by value, and for an object-typed - * variable ("by value" copies only the handle) that value is a reference to - * the very same underlying container. - * - * These tests exercise Language.php::init() through container types that - * mirror what artifacts/bootstrap_*.php actually builds at runtime (see - * ILIAS\Component\Dependencies\Renderer::renderComponent()), so a - * regression that only breaks when a real, mutable, lazily-evaluated - * container is used (as opposed to a plain PHP array test double) would - * still be caught here. - * - * $use only seeds \ILIAS\Language\Language::class, the RBAC service the - * Activities need and Directories: Language.php::init() does - * not (any longer) read a \ILIAS\UI\Factory entry from $use - seeding one - * here regardless would silently keep passing even if init() started reading - * it again incorrectly (e.g. the old $ui_factory dependency reappearing by - * accident), since a mock nobody asserts against never fails a test. + * $use is seeded only with what init() reads: a mock nobody asserts against would hide a reappearing $ui_factory dependency. */ class LanguageComponentGraphTest extends TestCase { @@ -95,11 +72,7 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ \ILIAS\Database\Connection::class => $connection ?? $this->createStub(\ILIAS\Database\Connection::class), ]; - // $implement and $contribute are wrapped in RenamingDIC in the real - // generated bootstrap (see Renderer::renderComponent()) so that - // several assignments to the very same key don't collide - a plain - // Pimple\Container would silently let the second assignment - // overwrite the first. + // RenamingDIC (as in the generated bootstrap) keeps several assignments to one key apart; a plain Pimple\Container overwrites $implement = new RenamingDIC(new PimpleContainer()); $contribute = new RenamingDIC(new PimpleContainer()); $provide = new PimpleContainer(); @@ -146,9 +119,7 @@ public function testProvidedServicesShareTheSameInternalSingletonInstances(): vo { [, , , , , $provide, , $internal] = $this->initComponent(); - // $provide[...] is only a thin re-export of $internal[...]; both - // must resolve to the exact same object (Pimple caches the closure - // result), not merely to objects of the same class. + // $provide[...] re-exports $internal[...]: both must be the same object self::assertSame($internal[LanguageFileDirectoryManager::class], $provide[LanguageFileDirectoryManager::class]); self::assertSame($internal[InstallLanguage::class], $provide[InstallLanguage::class]); self::assertSame($internal[UpdateLanguage::class], $provide[UpdateLanguage::class]); @@ -169,27 +140,8 @@ public function testContributeEntriesResolveToExpectedConcreteClassesInPreserved { [, , , $contribute] = $this->initComponent(); - // RenamingDIC assigns a single, container-wide "_" suffix - // to every offsetSet call, in call order. Reading these exact keys - // back pins the relative order of the ten $contribute entries, - // which the reorganisation was explicitly required to preserve - - // including the seven \ILIAS\Component\Activities\Activity::class - // entries (InstallLanguage, then UpdateLanguage, then - // UninstallLanguage, then RemoveLocalLanguageChanges, then - // AddLanguageEntry, then SetLanguageDetectionEnabled, then - // SetLanguageTranslationEnabled), one per Activity this component - // offers. The UserSettings contribution's counter shifted from _4 - // to _5 when UninstallLanguage's own Activity contribution was - // added ahead of it, then from _5 to _6 when - // RemoveLocalLanguageChanges' Activity contribution was added ahead - // of it in turn, then from _6 to _7 with AddLanguageEntry's Activity - // contribution added ahead of it, then from _7 to _8 with - // SetLanguageDetectionEnabled's Activity contribution added ahead of - // it, and has now shifted again from _8 to _9 with - // SetLanguageTranslationEnabled's Activity contribution added ahead - // of it - a hardcoded '_8' for UserSettingsSettings would otherwise - // silently start resolving to the wrong contribute entry (or, as - // happened before, to no entry at all). + // RenamingDIC suffixes every offsetSet with a container-wide "_" in call order, so these keys pin the order of + // the $contribute entries: a hardcoded counter shifts whenever a contribution is added ahead of it self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); @@ -206,12 +158,7 @@ public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstan { [, , , $contribute, , $provide] = $this->initComponent(); - // ilLanguageSetupAgent (Setup path) and ilObjLanguageFolderGUI - // (via $provide[InstallLanguage::class]) must operate on the exact - // same InstallLanguage instance, not merely equal ones - and the - // same holds for UpdateLanguage, UninstallLanguage, - // RemoveLocalLanguageChanges, AddLanguageEntry, - // SetLanguageDetectionEnabled and SetLanguageTranslationEnabled. + // the Setup path and the GUI must share the very same Activity instances, not merely equal ones self::assertSame( $provide[InstallLanguage::class], $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] @@ -246,13 +193,7 @@ public function testImplementCandidatesAreOfferedInDeclarationOrder(): void { [, $implement] = $this->initComponent(); - // The Setup-path candidate (\ilSetupLanguage) must be registered - // before the runtime/Init-path candidate - // (LanguageLegacyInitialisationAdapter) - consumers disambiguate - // by position (see cli/build_bootstrap.php / - // Renderer::renderUse()), so a swap here would silently flip which - // candidate every $use[\ILIAS\Language\Language::class] consumer - // gets wired to. + // consumers disambiguate by position (Renderer::renderUse()): the Setup candidate must be registered before the runtime one self::assertInstanceOf(\ilSetupLanguage::class, $implement[\ILIAS\Language\Language::class . '_0']); self::assertInstanceOf( LanguageLegacyInitialisationAdapter::class, @@ -303,10 +244,7 @@ private function databaseExpectingQueries(string $class, int $queries): \ilDBInt return $db; } - /** - * Repository and AddLanguageEntry query the pulled connection, never the - * database in $GLOBALS['ilDB']. - */ + /** Repository and AddLanguageEntry query the pulled connection, never $GLOBALS['ilDB']. */ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void { $dic = new \ILIAS\DI\Container(); @@ -318,15 +256,11 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $this->resolveEverything($provide, $contribute); self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); - // The default of AddLanguageEntry's module cache update; the public path - // to it also calls the legacy static ilObjLanguage::replaceLangEntry(). + // default of AddLanguageEntry's module cache update, whose public path also calls the static ilObjLanguage::replaceLangEntry() self::readPrivateProperty($internal[AddLanguageEntry::class], 'update_module_cache')('de', 'common', 'x', 'y'); self::assertSame($dic, $GLOBALS['DIC']); } - /** - * Language must not call RBAC->system() while the services are resolved. - */ public function testWiringNeverCallsRbacSystemWhileResolving(): void { $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class); @@ -358,9 +292,7 @@ public function offsetGet(mixed $key): mixed self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); } - /** - * @return array}> - */ + /** @return array}> */ public static function setupLanguageActivities(): array { return [ @@ -369,9 +301,6 @@ public static function setupLanguageActivities(): array ]; } - /** - * Not installed, so perform() only reads the installed languages. - */ private function performReadOnly(InstallLanguage|UpdateLanguage $activity): void { $activity->perform( @@ -381,9 +310,7 @@ private function performReadOnly(InstallLanguage|UpdateLanguage $activity): void ); } - /** - * @param class-string $class - */ + /** @param class-string $class */ #[DataProvider('setupLanguageActivities')] public function testWebActivitiesReadTheInstalledLanguagesFromThePulledConnection(string $class): void { diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index f669fddf5e2f..9aa178d55cbf 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -25,10 +25,7 @@ use PHPUnit\Framework\TestCase; /** - * Unit tests for the RendersActivityErrors trait itself (see its own class - * docblock), in isolation from any concrete GUI class - via - * RendersActivityErrorsTestHost, a minimal class that only mixes in the - * trait and exposes activityErrorMessage() through a public wrapper. + * The RendersActivityErrors trait in isolation, via RendersActivityErrorsTestHost. */ class RendersActivityErrorsTest extends TestCase { @@ -79,22 +76,11 @@ public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDispla $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) ->callActivityErrorMessage($error); - // The domain layer (UninstallLanguage/RemoveLocalLanguageChanges etc.) - // deliberately embeds the raw, unescaped title in the exception - // message - activityErrorMessage() is the one seam that HTML-escapes - // it for safe display, so the '"' characters must come back as - // '"' here. + // the domain layer embeds the raw title in the message: activityErrorMessage() is the one seam that escapes it $this->assertSame('Multiple language objects share the title(s) "fr".', $message); } - /** - * Regression test for the escaping seam itself: a SafeToDisplayActivityError - * message containing genuinely dangerous markup (not just a stray quote) - * must come back HTML-escaped - proving activityErrorMessage() is not - * merely quote-escaping by coincidence but actually protects against - * XSS-shaped content that a caller-controlled language key/title/field - * name could carry. - */ + /** Dangerous markup (not just a stray quote) must come back HTML-escaped. */ public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void { $logger = $this->createMock(\ilLogger::class); @@ -112,24 +98,12 @@ public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlE $this->assertStringNotContainsString('', $error->getMessage()); } @@ -265,7 +258,7 @@ public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOf $this->assertTrue($result->isError()); $error = $result->error(); $this->assertInstanceOf(InvalidInputException::class, $error); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $error); + $this->assertSame(400, $error->getCode()); $this->assertStringContainsString( 'Expected an array of values (or none at all) for translations, got: ' . get_debug_type($raw_value), $error->getMessage() diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index 62819ad8a63c..b5b0f8b3ea1a 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -629,48 +629,6 @@ public function testPermissionDeniedBeforePerform(): void $this->assertTrue($result->isError()); } - /** - * Regression test for LanguageActivity::maybePerformAs()'s generic - * catch(\Throwable) block (InstallLanguage itself does not override - * maybePerformAs() - this exercises the base class implementation): - * a \Throwable that is NOT an \Exception (here a \TypeError, the other - * half of the \Throwable hierarchy) raised from within perform() must - * still come back as a Result\Error carrying an \Exception instance - * (here a \RuntimeException wrapping the original \TypeError as its - * "previous"), per the Activity::maybePerformAs() contract ("Wraps the - * result and possible errors in the Result type"). Before the fix, - * `new Result\Error($e)` itself raised an uncaught - * \InvalidArgumentException for such a $e (Result\Error::__construct() - * only accepts string|\Exception), so maybePerformAs() aborted instead - * of returning a Result at all. - */ - public function testAThrowableThatIsNotAnExceptionFromWithinPerformIsWrappedInARuntimeExceptionResultError(): void - { - $rbac = $this->createMock(\ilRbacSystem::class); - $rbac->method('checkAccessOfUser')->willReturn(true); - - $setup_language = $this->createSetupLanguageMock([], [], []); - // raised inside perform()'s loop, after isAllowedToPerform() succeeded - $setup_language->method('checkLanguageForInstallation')->willReturnCallback( - static function (): bool { - throw new \TypeError('simulated TypeError, not an \Exception'); - } - ); - - $result = $this->createActivity($setup_language, $rbac)->maybePerformAs( - $this->createRealFieldsUiFactory()->input(), - 6, - ['language_keys' => ['de'], 'mode' => InstallLanguage::MODE_INSTALL] - ); - - $this->assertTrue($result->isError()); - $error = $result->error(); - $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertNotInstanceOf(\TypeError::class, $error); - $this->assertSame('simulated TypeError, not an \Exception', $error->getMessage()); - $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); - } - private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 4fc3897a4334..d129e9bfe365 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -40,9 +40,10 @@ class LanguageActivityTest extends ilLanguageBaseTestCase use RealFieldsUiFactory; /** - * A \Throwable while building the input description must come back as a Result\Error, without reaching rbac. + * A defect while building the input description comes back masked as 500 (original only as previous), + * without reaching rbac. */ - public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResultErrorNotPropagated(): void + public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->expects($this->never())->method('checkAccessOfUser'); @@ -59,36 +60,48 @@ public function testExceptionWhileBuildingTheInputDescriptionIsTurnedIntoAResult $this->assertTrue($result->isError()); $error = $result->error(); $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertSame('simulated failure while building the input description', $error->getMessage()); + $this->assertSame(500, $error->getCode()); + $this->assertSame('The activity could not be performed.', $error->getMessage()); + $this->assertInstanceOf(\RuntimeException::class, $error->getPrevious()); + $this->assertSame('simulated failure while building the input description', $error->getPrevious()->getMessage()); } - /** - * A non-\Exception \Throwable (\TypeError) must arrive wrapped in a \RuntimeException, since Result\Error - * accepts only string|\Exception. - */ - public function testATypeErrorWhileBuildingTheInputDescriptionIsWrappedInARuntimeExceptionResultError(): void + /** A non-\Exception \Throwable (\TypeError) is masked as well. */ + public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { - $rbac = $this->createMock(\ilRbacSystem::class); - $rbac->expects($this->never())->method('checkAccessOfUser'); - $activity = new LanguageActivityThrowingInputDescriptionTestDouble( $this->createMock(RefineryFactory::class), $this->createMock(Language::class), - $this->rbacOf($rbac), + $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), throw_type_error: true, ); - $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, []); + $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [])->error(); - $this->assertTrue($result->isError()); - $error = $result->error(); $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertNotInstanceOf(\TypeError::class, $error); - $this->assertSame('simulated TypeError while building the input description', $error->getMessage()); + $this->assertSame(500, $error->getCode()); + $this->assertStringNotContainsString('TypeError', $error->getMessage()); $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); } + /** grind() throws on a defect (FormInput without InputInternal); maybePerformAs() must mask it as 500. */ + public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void + { + $activity = new LanguageActivityMergePrecedenceTestDouble( + $this->createMock(RefineryFactory::class), + $this->createMock(Language::class), + $this->rbacOf($this->createMock(\ilRbacSystem::class)), + $this->languageFolder(), + $this->createMock(FormInput::class), + ); + + $error = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [])->error(); + + $this->assertSame(500, $error->getCode()); + $this->assertInstanceOf(\LogicException::class, $error->getPrevious()); + } + /** * additionalPerformParameters() must win over a same-named key from normalizeParameters() in maybePerformAs()'s * array_merge(). Needs a dedicated double, as AddLanguageEntry never collects a 'usr_id' field. @@ -167,6 +180,16 @@ protected function normalizeParameters(array $grind_result): array */ final class LanguageActivityMergePrecedenceTestDouble extends LanguageActivity { + public function __construct( + RefineryFactory $refinery, + Language $language, + RBAC $rbac, + LanguageFolder $language_folder, + private readonly ?FormInput $description = null, + ) { + parent::__construct($refinery, $language, $rbac, $language_folder); + } + public function getDescription(): Text\SimpleDocumentMarkdown { return $this->markdown('Test double for maybePerformAs() array_merge() precedence.'); @@ -174,7 +197,7 @@ public function getDescription(): Text\SimpleDocumentMarkdown public function getInputDescription(FieldFactory $f): FormInput { - return $f->group([]); + return $this->description ?? $f->group([]); } public function getOutputDescription(Description\Factory $f): Description\Description diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index 84137def9653..b2c37d257179 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\AmbiguousLanguageTitleException; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Language; use ILIAS\Refinery\Factory as RefineryFactory; use ILIAS\UI\Component\Input\Factory as InputFactory; @@ -545,8 +544,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void * exception class, not just \InvalidArgumentException (which * InvalidInputException extends, and against which this assertion would * therefore have kept passing silently even after the behaviour change - - * see RendersActivityErrors, which treats InvalidInputException/ - * SafeToDisplayActivityError specially). + * see RendersActivityErrors, which treats 4xx specially). */ public function testNonArrayParametersAreRejected(): void { @@ -666,7 +664,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index 09b154b43a26..e9622073a67a 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -17,7 +17,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Checkbox; @@ -207,13 +206,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWrites $this->assertTrue($result->isError()); } - /** - * A Throwable raised from within perform() (or normalizeParameters()) - * must be turned into a Result\Error rather than propagating - this is - * the whole point of maybePerformAs() wrapping perform() in a - * try/catch. - */ - public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoAResultError(): void + public function testMaybePerformAsRejectsAnEnabledValueOutsideTheCheckboxWhitelistAsA400WithoutTouchingSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); @@ -227,7 +220,7 @@ public function testMaybePerformAsTurnsAThrowableFromNormalizeParametersIntoARes $this->assertTrue($result->isError()); // outside the whitelist of normalizeCheckboxRawValue(): grind() turns it into an InvalidInputException $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** @@ -345,7 +338,7 @@ public function testMaybePerformAsRejectsValuesGrindCannotInterpretAsACheckboxWi $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } public function testInputDescriptionBuildsAGroupWithASingleEnabledCheckboxField(): void diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index 261090a99157..4a6b59d9ac12 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -17,7 +17,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; -use ILIAS\Language\Activities\SafeToDisplayActivityError; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Text; @@ -251,7 +250,8 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); $this->assertTrue($result->isError()); - $this->assertSame('no write permission', $result->error()); + $this->assertInstanceOf(\DomainException::class, $result->error()); + $this->assertSame(403, $result->error()->getCode()); } /** @@ -260,7 +260,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche * rather than propagating - this is the whole point of maybePerformAs() * wrapping perform() in a try/catch. */ - public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void + public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); @@ -274,8 +274,8 @@ public function testThrowableFromWithinPerformIsTurnedIntoAResultError(): void $this->assertTrue($result->isError()); $error = $result->error(); - $this->assertInstanceOf(\RuntimeException::class, $error); - $this->assertSame('database write failed', $error->getMessage()); + $this->assertSame(500, $error->getCode()); + $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); } /** @@ -353,7 +353,7 @@ public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithou $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** @@ -408,7 +408,7 @@ public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermis $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } public static function tolerantlyAcceptedTruthyEnabledValuesProvider(): array @@ -548,7 +548,7 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $this->assertTrue($result->isError()); $this->assertInstanceOf(InvalidInputException::class, $result->error()); - $this->assertInstanceOf(SafeToDisplayActivityError::class, $result->error()); + $this->assertSame(400, $result->error()->getCode()); } /** diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index 9aa178d55cbf..e5d3504d874f 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -21,7 +21,9 @@ namespace ILIAS\Language; use ILIAS\Language\Activities\AmbiguousLanguageTitleException; +use ILIAS\Language\Activities\Failure; use ILIAS\Language\Activities\InvalidInputException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -42,64 +44,52 @@ private function languageMockReturningTopicAsIs(): \ilLanguage return $lng; } - public function testAPlainStringErrorIsReturnedAsIsAndNeverLogged(): void + public function testCode403ShowsTheNoPermissionMessageAndIsNeverLogged(): void { $logger = $this->createMock(\ilLogger::class); $logger->expects($this->never())->method('error'); $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage('msg_no_perm_write'); + ->callActivityErrorMessage(new \DomainException('internal wording ', 403)); $this->assertSame('msg_no_perm_write', $message); } - public function testASafeToDisplayActivityErrorMessageIsReturnedAsIsAndNeverLogged(): void + /** @return array>|list> */ + public static function clientErrorProvider(): array { - $logger = $this->createMock(\ilLogger::class); - $logger->expects($this->never())->method('error'); - - $error = new InvalidInputException('language_keys: not_min_length'); - - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage($error); - - $this->assertSame('language_keys: not_min_length', $message); + return [ + 'invalid input' => [ + new InvalidInputException('language_keys: not_min_length'), + 'language_keys: not_min_length', + ], + 'ambiguous title embeds the raw title' => [ + new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'), + 'Multiple language objects share the title(s) "fr".', + ], + 'dangerous markup' => [ + new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'), + 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', + ], + 'any other 4xx (404)' => [new \RuntimeException('gone', 404), '<i>gone</i>'], + ]; } - public function testAnAmbiguousLanguageTitleExceptionIsAlsoTreatedAsSafeToDisplayAndNeverLogged(): void + /** Every 4xx other than 403 is shown, HTML-escaped (setOnScreenMessage() renders unescaped), and never logged. */ + #[DataProvider('clientErrorProvider')] + public function testA4xxErrorMessageIsHtmlEscapedAndNeverLogged(\Exception $error, string $expected): void { $logger = $this->createMock(\ilLogger::class); $logger->expects($this->never())->method('error'); - $error = new AmbiguousLanguageTitleException('Multiple language objects share the title(s) "fr".'); - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) ->callActivityErrorMessage($error); - // the domain layer embeds the raw title in the message: activityErrorMessage() is the one seam that escapes it - $this->assertSame('Multiple language objects share the title(s) "fr".', $message); - } - - /** Dangerous markup (not just a stray quote) must come back HTML-escaped. */ - public function testASafeToDisplayActivityErrorMessageWithDangerousMarkupIsHtmlEscaped(): void - { - $logger = $this->createMock(\ilLogger::class); - $logger->expects($this->never())->method('error'); - - $error = new InvalidInputException('Unknown key(s) for & "quoted" \'single\'.'); - - $message = $this->host($logger, $this->languageMockReturningTopicAsIs()) - ->callActivityErrorMessage($error); - - $this->assertSame( - 'Unknown key(s) for <script>alert(1)</script> & "quoted" 'single'.', - $message - ); - $this->assertStringNotContainsString('&"quoted"'; try { - $this->createActivity(settings: $settings) + $this->createActivity(repository: $repository) ->perform(['language_key' => $dangerous_key, 'enabled' => true]); $this->fail('Expected an InvalidInputException to be thrown.'); } catch (InvalidInputException $e) { @@ -536,11 +521,11 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $rbac = $this->createMock(\ilRbacSystem::class); $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); - $settings = $this->createMock(Setting::class); - $settings->expects($this->never())->method('get'); - $settings->expects($this->never())->method('set'); + $repository = $this->createMock(RepositoryProvider::class); + $repository->expects($this->never())->method('isPageTranslationEnabled'); + $repository->expects($this->never())->method('setPageTranslationEnabled'); - $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'xx', 'enabled' => true]); $this->assertTrue($result->isError()); @@ -561,11 +546,11 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $rbac = $this->createMock(\ilRbacSystem::class); $rbac->method('checkAccessOfUser')->willReturn(true); - $settings = $this->createMock(Setting::class); - $settings->method('get')->with('lang_translate_de', '0')->willReturn('0'); - $settings->expects($this->once())->method('set')->with('lang_translate_de', '1'); + $repository = $this->createMock(RepositoryProvider::class); + $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); - $result = $this->createActivity(rbac_system: $rbac, settings: $settings) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => ' de ', 'enabled' => true]); $this->assertFalse($result->isError()); @@ -635,18 +620,19 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): private function createActivity( ?\ilRbacSystem $rbac_system = null, - ?Setting $settings = null, + ?RepositoryProvider $repository = null, ?Language $language = null, int $language_folder_ref_id = 0, - ?InstalledLanguageRepository $installed_language_repository = null, + array $installed_languages = ['de', 'fr'], ): SetLanguageTranslationEnabled { + $repository ??= $this->createMock(RepositoryProvider::class); + $repository->method('getInstalledLanguages')->willReturn($installed_languages); + return new SetLanguageTranslationEnabled( $language ?? $this->createMock(Language::class), $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)), - $settings ?? $this->createMock(Setting::class), - installed_language_repository: $installed_language_repository - ?? new FakeInstalledLanguageRepository(static fn(): array => ['de', 'fr']), - language_folder: $this->languageFolder($language_folder_ref_id), + $this->languageFolder($language_folder_ref_id), + $repository, ); } } diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 3a69d2e05264..0437c35c6d3e 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -36,6 +36,8 @@ */ class UninstallLanguageTest extends ActivityWithPerformResultContractTestCase { + use LanguageObjectRepositoryProvider; + protected function createDefaultActivity(): UninstallLanguage { [, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld(['de' => []]); @@ -677,8 +679,7 @@ private function createActivity( $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder(), - $lng_objects, - $obj_language_factory + $this->languageObjectProvider($lng_objects, $obj_language_factory) ); } } diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 14da7e83a905..84a884e66bc8 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -24,6 +24,7 @@ use ILIAS\Language\Activities\UninstallLanguage; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; use ILIAS\Language\Activities\AddLanguageEntry; +use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; use ILIAS\Language\ComponentTranslation\LanguageFileDirectory; @@ -244,7 +245,7 @@ private function databaseExpectingQueries(string $class, int $queries): \ilDBInt return $db; } - /** Repository and AddLanguageEntry query the pulled connection, never $GLOBALS['ilDB']. */ + /** Repository and RepositoryProvider query the pulled connection, never $GLOBALS['ilDB']. */ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void { $dic = new \ILIAS\DI\Container(); @@ -256,8 +257,8 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $this->resolveEverything($provide, $contribute); self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); - // default of AddLanguageEntry's module cache update, whose public path also calls the static ilObjLanguage::replaceLangEntry() - self::readPrivateProperty($internal[AddLanguageEntry::class], 'update_module_cache')('de', 'common', 'x', 'y'); + // the module cache update of the provider shared by the runtime Activities; only its lookup runs without a legacy container + $internal[RepositoryProvider::class]->updateModuleCache('de', 'common', 'x', 'y'); self::assertSame($dic, $GLOBALS['DIC']); } From d18dc7c474e48a1d3da226984e5cc7dbafb0eed0 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 11:50:55 +0200 Subject: [PATCH 15/29] Language: Mask database errors whose code is a SQLSTATE string A `PDOException` carries its SQLSTATE as string code, and `'42S02'` compares as a 4xx code, so `Failure::of()` and `RendersActivityErrors` showed the database message as input error. Both now require an integer code. The test doubles no longer call the removed `markdown()` helper. --- .../ILIAS/Language/src/Activities/Failure.php | 4 +++- .../Language/src/RendersActivityErrors.php | 4 +++- .../Language/tests/Activities/FailureTest.php | 14 +++++++++++++ .../tests/Activities/LanguageActivityTest.php | 14 ++++++------- .../Activities/UninstallLanguageTest.php | 10 ++-------- .../tests/RendersActivityErrorsTest.php | 20 +++++++++++++++++++ 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/components/ILIAS/Language/src/Activities/Failure.php b/components/ILIAS/Language/src/Activities/Failure.php index 630365328cf3..ab8c80a0b893 100644 --- a/components/ILIAS/Language/src/Activities/Failure.php +++ b/components/ILIAS/Language/src/Activities/Failure.php @@ -28,7 +28,9 @@ final class Failure { public static function of(\Throwable $e): \Exception { - if ($e instanceof \Exception && $e->getCode() >= 400 && $e->getCode() < 500) { + // is_int(): a PDOException carries its SQLSTATE as string, and '42S02' compares as 4xx + $code = $e->getCode(); + if ($e instanceof \Exception && is_int($code) && $code >= 400 && $code < 500) { return $e; } diff --git a/components/ILIAS/Language/src/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php index 5dba8d4ad283..a7a7e1c58fa6 100644 --- a/components/ILIAS/Language/src/RendersActivityErrors.php +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -53,7 +53,9 @@ private function activityErrorMessage(\Throwable $error): string /** A 4xx other than 403: the call itself was rejected, its message is meant for the caller. */ private function isActivityInputError(\Throwable $error): bool { - return $error->getCode() >= 400 && $error->getCode() < 500 && $error->getCode() !== 403; + $code = $error->getCode(); + + return is_int($code) && $code >= 400 && $code < 500 && $code !== 403; } /** diff --git a/components/ILIAS/Language/tests/Activities/FailureTest.php b/components/ILIAS/Language/tests/Activities/FailureTest.php index 532cd0a3c56b..43a75f656087 100644 --- a/components/ILIAS/Language/tests/Activities/FailureTest.php +++ b/components/ILIAS/Language/tests/Activities/FailureTest.php @@ -56,9 +56,23 @@ public static function defectProvider(): array 'above 4xx (600)' => [new \RuntimeException('secret', 600)], 'Error with 4xx code' => [new \TypeError('secret', 400)], 'plain Error' => [new \Error('secret')], + 'PDOException, SQLSTATE 42S02 as string code' => [self::pdoExceptionWithSqlState('42S02')], + 'PDOException, SQLSTATE 42S22 as string code' => [self::pdoExceptionWithSqlState('42S22')], ]; } + /** PDOException carries its SQLSTATE as string code, which compares numerically against 400..499 in PHP 8. */ + private static function pdoExceptionWithSqlState(string $sql_state): \PDOException + { + return new class ('SQLSTATE: secret table', $sql_state) extends \PDOException { + public function __construct(string $message, string $sql_state) + { + parent::__construct($message); + $this->code = $sql_state; + } + }; + } + #[DataProvider('defectProvider')] public function testOfMasksEverythingElseAsGeneric500KeepingTheOriginalAsPrevious(\Throwable $defect): void { diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 2c277e36c470..7bf7bf7bb6f2 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -99,20 +99,20 @@ public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void $this->assertInstanceOf(\LogicException::class, $error->getPrevious()); } - /** @return array */ + /** @return array */ public static function unknownUserIdProvider(): array { - return ['zero' => [0], 'negative' => [-1], 'anonymous' => [13]]; + return ['zero' => [0], 'negative' => [-1], 'anonymous' => [null]]; } - /** No RBAC lookup for a non-user: the anonymous user (13 in ILIAS) must not pass on its roles. */ + /** No RBAC lookup for a non-user: the anonymous user (null: ANONYMOUS_USER_ID) must not pass on its roles. */ #[DataProvider('unknownUserIdProvider')] - public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(int $usr_id): void + public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(?int $usr_id): void { if (!defined('ANONYMOUS_USER_ID')) { define('ANONYMOUS_USER_ID', 13); } - $this->assertSame(13, ANONYMOUS_USER_ID); + $usr_id ??= ANONYMOUS_USER_ID; $rbac_system = $this->createMock(\ilRbacSystem::class); $rbac_system->expects($this->never())->method('checkAccessOfUser'); @@ -184,7 +184,7 @@ public function __construct( public function getDescription(): Text\SimpleDocumentMarkdown { - return $this->markdown('Test double for LanguageActivity::maybePerformAs().'); + return $this->data->text()->markdown()->simpleDocument('Test double for LanguageActivity::maybePerformAs().'); } public function getInputDescription(FieldFactory $f): FormInput @@ -229,7 +229,7 @@ public function __construct( public function getDescription(): Text\SimpleDocumentMarkdown { - return $this->markdown('Test double for the usr_id precedence in maybePerformAs().'); + return $this->data->text()->markdown()->simpleDocument('Test double for the usr_id precedence in maybePerformAs().'); } public function getInputDescription(FieldFactory $f): FormInput diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 0437c35c6d3e..612a964ed652 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -25,14 +25,8 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * UninstallLanguage has no ilSetupLanguage collaborator (unlike - * InstallLanguage/UpdateLanguage) - instead it resolves language keys to - * objects via two closures, $lng_objects and $obj_language_factory. The - * object returned by $obj_language_factory must behave like \ilObjLanguage - * (isSystemLanguage(), isUserLanguage(), isInstalled(), uninstall()), but - * \ilObjLanguage itself cannot be constructed or meaningfully mocked in a - * unit test (its constructor needs the global $DIC) - so tests use a small - * fake object instead, injected through $obj_language_factory. + * UninstallLanguage works on language objects behind a mocked RepositoryProvider, answering from small fake + * objects (\ilObjLanguage needs the global $DIC) via LanguageObjectRepositoryProvider. */ class UninstallLanguageTest extends ActivityWithPerformResultContractTestCase { diff --git a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php index e5d3504d874f..854f062b5c9a 100644 --- a/components/ILIAS/Language/tests/RendersActivityErrorsTest.php +++ b/components/ILIAS/Language/tests/RendersActivityErrorsTest.php @@ -105,6 +105,17 @@ public function testA4xxErrorMessageWithInvalidUtf8IsNotSilentlyDroppedToAnEmpty $this->assertStringEndsWith('": bad input.', $message); } + private static function pdoExceptionWithSqlState(string $sql_state, string $message): \PDOException + { + return new class ($message, $sql_state) extends \PDOException { + public function __construct(string $message, string $sql_state) + { + parent::__construct($message); + $this->code = $sql_state; + } + }; + } + /** @return array>|list> */ public static function nonClientErrorProvider(): array { @@ -112,6 +123,8 @@ public static function nonClientErrorProvider(): array 'code 0' => [new \RuntimeException('boom')], 'code 399' => [new \RuntimeException('boom', 399)], 'code 500' => [new \RuntimeException('boom', 500)], + 'PDOException, SQLSTATE 42S02 as string code' => [self::pdoExceptionWithSqlState('42S02', 'boom')], + 'PDOException, SQLSTATE 42S22 as string code' => [self::pdoExceptionWithSqlState('42S22', 'boom')], ]; } @@ -153,6 +166,13 @@ public static function inputErrorCodeProvider(): array return [[399, false], [400, true], [403, false], [404, true], [409, true], [499, true], [500, false]]; } + public function testIsActivityInputErrorIsFalseForAStringSqlStateCode(): void + { + $host = $this->host($this->createMock(\ilLogger::class), $this->languageMockReturningTopicAsIs()); + + $this->assertFalse($host->callIsActivityInputError(self::pdoExceptionWithSqlState('42S02', 'x'))); + } + #[DataProvider('inputErrorCodeProvider')] public function testIsActivityInputErrorIsTrueOnlyFor4xxExcept403(int $code, bool $expected): void { From f614be7b88e926d2819a83d0e0bda8a7ca7514dc Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 11:55:30 +0200 Subject: [PATCH 16/29] Language: Shorten the comments of the Activities, their wiring and tests Keeps only what the code does not say itself, as in the File component: the reasons in `Language.php` shrink to the two real traps (no `final` Activities, the order of the contributions), and the tests lose their history notes. --- components/ILIAS/Language/Language.php | 76 +-- .../src/Activities/AddLanguageEntry.php | 9 +- .../Language/src/RendersActivityErrors.php | 4 +- .../Activities/ActivityContractTestCase.php | 10 +- .../tests/Activities/AddLanguageEntryTest.php | 146 +---- .../tests/Activities/GrindsFormInputTest.php | 30 +- .../tests/Activities/InstallLanguageTest.php | 81 +-- .../tests/Activities/LanguageActivityTest.php | 3 - .../RemoveLocalLanguageChangesTest.php | 209 +------ .../SetLanguageDetectionEnabledTest.php | 53 +- .../SetLanguageTranslationEnabledTest.php | 123 +--- .../Activities/UninstallLanguageTest.php | 161 +----- .../tests/Activities/UpdateLanguageTest.php | 29 +- .../tests/ilLanguageSetupAgentTest.php | 3 - .../tests/ilObjLanguageExtGUITest.php | 155 +---- .../tests/ilObjLanguageFolderGUITest.php | 530 ++---------------- 16 files changed, 194 insertions(+), 1428 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 7d54748dcbd3..cc467bdec4de 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -49,9 +49,7 @@ public function init( ): void { $define[] = \ILIAS\Language\Language::class; - // --- $internal: wiring local to this component ----------------- - // Ordered so each entry's dependencies are declared above it. - + // each entry's dependencies are declared above it $internal[Language\LanguageFolder::class] = static fn() => new Language\LanguageFolderLegacyProxy(); @@ -61,9 +59,6 @@ public function init( ...$seek[LanguageFileDirectory::class] ); - // Read access to the language installation domain, extracted from - // ilSetupLanguage per docs/development/repository-pattern.md - see that - // class' docblock. $internal[InstalledLanguageDatabaseRepository::class] = static fn() => new InstalledLanguageDatabaseRepository( $pull[\ILIAS\Database\Connection::class], @@ -71,8 +66,7 @@ public function init( $use[Directories::class]->getRoot() ); - // Setup instance: Setup hands in its own database via setDbHandler() - // instead of the Connection (see components/ILIAS/Database/ROADMAP.md). + // Setup hands in its own database via setDbHandler() instead of the Connection $internal[\ilSetupLanguage::class] = static fn() => new \ilSetupLanguage( "en", @@ -102,7 +96,7 @@ public function init( $internal[Language\LanguageFolder::class] ); - // runtime-only Activities (no Setup counterpart): all their legacy access goes through this provider + // runtime-only Activities route all legacy access through this provider $internal[RepositoryProvider::class] = static fn() => new RepositoryProvider( $internal[InstalledLanguageDatabaseRepository::class], @@ -149,48 +143,22 @@ public function init( $internal[RepositoryProvider::class], ); - // LanguageLegacyInitialisationAdapter has no constructor of its own, - // so it never needs the LanguageFileDirectoryManager argument - - // it purely proxies to $DIC->language() at call time. This slot used - // to be misleadingly named $internal[\ilLanguage::class] even though - // it never held an \ilLanguage instance. + // only proxies to $DIC->language() at call time, hence no constructor arguments $internal[Language\LanguageLegacyInitialisationAdapter::class] = static fn() => new Language\LanguageLegacyInitialisationAdapter(); - // --- $implement -------------------------------------------------- - - // This component registers TWO candidate implementations for - // \ILIAS\Language\Language. Both assignments below are intentional - - // ILIAS\Component\Dependencies\Reader::cacheImplement() collects every - // $implement[...] assignment into a list rather than overwriting a - // plain array key, and the generated RenamingDIC keeps each candidate - // under its own offset, so neither line is dead code. - // Consumers that $use[\ILIAS\Language\Language::class] (e.g. UI.php, - // Setup.php, Refinery.php) are disambiguated per bootstrap entry point: - // - components/ILIAS/Setup/resources/dependency_resolution.php -> ilSetupLanguage - // - components/ILIAS/Init/resources/dependency_resolution.php -> LanguageLegacyInitialisationAdapter - // See docs/development/components-and-directories.md and cli/build_bootstrap.php. + // Two candidates on purpose, neither line is dead: every $implement assignment is collected, + // and each bootstrap entry point picks one in its resources/dependency_resolution.php + // (Setup: ilSetupLanguage, Init: LanguageLegacyInitialisationAdapter). $implement[\ILIAS\Language\Language::class] = static fn() => $internal[\ilSetupLanguage::class]; $implement[\ILIAS\Language\Language::class] = static fn() => $internal[Language\LanguageLegacyInitialisationAdapter::class]; - // --- $provide: services made available to the rest of the system - - - // Make the resolved language services available outside this component. $provide[LanguageFileDirectoryManager::class] = static fn() => $internal[LanguageFileDirectoryManager::class]; - // --- $contribute: contributions to other components' collection - // points. Relative order preserved from before this file's - // reorganisation, since components may in general add multiple - // contributions to the same collection point sequentially (see - // docs/development/components-and-directories.md, "Contribute to - // Service or Functionality") - which the seven - // \ILIAS\Component\Activities\Activity::class entries below - // actually do, one per Activity this component offers. - $contribute[LanguageFileDirectory::class] = static fn() => new MainLanguageFileDirectory(); $contribute[\ILIAS\Setup\Agent::class] = static fn() => @@ -202,32 +170,10 @@ public function init( UpdateLanguage::forSetup($internal[\ilSetupLanguage::class]) ); - // The seven Activities this component offers are both provided under - // their own concrete class (so components/ILIAS/Init/Init.php can - // pull each one specifically - AllModernComponents.php then - // re-exposes that same resolved instance under the matching legacy - // $DIC[::class] key, which is what every GUI class in - // this component reads) and contributed generically to - // \ILIAS\Component\Activities\Activity::class (so the - // cross-component Repository can discover them too) - see - // docs/development/components-and-directories.md, "Contribute to - // Service or Functionality". Because Init.php pulls these concrete - // classes (not an interface), none of the seven may become final - both - // ilObjLanguageFolderGUITest and ilObjLanguageExtGUITest build a - // PHPUnit mock of several of them directly (createMock(InstallLanguage::class) - // and siblings), which requires PHPUnit to generate a subclass at - // runtime - impossible for a final class. - // - // Kept as one combined loop rather than seven repeated - // $provide[...]/$contribute[...] pairs. Order matters for the - // $contribute side only (RenamingDIC assigns a shared, container-wide - // "_" suffix in call order to disambiguate the repeated - // \ILIAS\Component\Activities\Activity::class key - see - // LanguageComponentGraphTest::testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder()); - // this list's order must therefore match $activities' declaration - // order exactly, and this loop must stay exactly here: after the two - // preceding $contribute entries (LanguageFileDirectory, Setup\Agent) - // and before the following one (UserSettings). + // Provided under the concrete class because Init.php pulls it, so none may become final + // (the GUI tests mock them). The $contribute order is the declaration order below and must + // stay between the Setup\Agent and the UserSettings entry: RenamingDIC numbers the repeated + // Activity key in call order (see LanguageComponentGraphTest). $activities = [ InstallLanguage::class, UpdateLanguage::class, diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php index c53e9b85d909..74e602fd14f8 100644 --- a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -56,17 +56,14 @@ public function getDescription(): Text\SimpleDocumentMarkdown } /** - * Must keep exactly `Activity::getInputDescription(FieldFactory $f): FormInput`'s signature: an - * optional extra parameter here would make a subclass using the plain signature a variance fatal. - * So the installed languages are resolved here, and may differ from perform()'s snapshot if a - * language is (un)installed in between; perform() fails closed for "de"/"en", no partial data. + * Signature must stay exactly the Activity's (no extra parameter), so the installed languages + * are resolved here and may differ from perform()'s snapshot; perform() then fails closed. */ public function getInputDescription(FieldFactory $f): FormInput { $installed_language_keys = $this->repository->getInstalledLanguages(); - // labels need the "meta" module loaded by the caller (not loaded here: it would clobber keys - // of other modules in the shared $lng); otherwise the placeholder "-meta_l_de-" shows + // labels need the "meta" module loaded by the caller; loading it here would clobber keys in the shared $lng $module = $f->text( 'Module', 'Name of the language module the new entry belongs to.' diff --git a/components/ILIAS/Language/src/RendersActivityErrors.php b/components/ILIAS/Language/src/RendersActivityErrors.php index a7a7e1c58fa6..489537cbefaf 100644 --- a/components/ILIAS/Language/src/RendersActivityErrors.php +++ b/components/ILIAS/Language/src/RendersActivityErrors.php @@ -58,9 +58,7 @@ private function isActivityInputError(\Throwable $error): bool return is_int($code) && $code >= 400 && $code < 500 && $code !== 403; } - /** - * Renders $error and every exception it was caused by (getPrevious()), so a wrapper never hides the original. - */ + /** Includes every getPrevious(), so a wrapper never hides the original. */ private function describeThrowableChain(\Throwable $error): string { $descriptions = []; diff --git a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php index 5c8fe85acd5c..91f79a0a1aa8 100644 --- a/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php +++ b/components/ILIAS/Language/tests/Activities/ActivityContractTestCase.php @@ -59,10 +59,7 @@ public function testGetDescriptionReturnsANonEmptyMarkdownDocument(): void $this->assertNotSame('', $this->createDefaultActivity()->getDescription()->getRawRepresentation()); } - /** - * No extra parameter, even an optional one: a parent declaring more parameters than a subclass with the - * plain interface signature is a PHP variance fatal error at class-load time. - */ + /** An extra parameter, even optional, is a variance fatal error at class-load time. */ public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfaceSignature(): void { $activity = $this->createDefaultActivity(); @@ -91,9 +88,8 @@ public function testGetInputDescriptionSignatureExactlyMatchesTheActivityInterfa } /** - * A RepositoryProvider mock answering per obj_id from small fake language objects (the legacy \ilObjLanguage - * cannot be built without $DIC). The factory is only asked when the Activity actually touches an object, so - * a factory that throws proves an object was never reached. + * RepositoryProvider mock answering per obj_id from fake language objects (\ilObjLanguage needs $DIC). + * The factory is only asked on access, so a throwing factory proves no object was reached. */ trait LanguageObjectRepositoryProvider { diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php index 6f5d29d6caa0..f19784e3a3e3 100644 --- a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -35,9 +35,7 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; /** - * AddLanguageEntry reads installed languages, writes entries and resolves the acting user's login (audit trail) - * through the RepositoryProvider, mocked here and answering from the closures of createActivity(). No test touches - * a real database or lang file. + * Mocks the RepositoryProvider, answering from the closures of createActivity(); no real database or lang file. */ class AddLanguageEntryTest extends ActivityContractTestCase { @@ -46,10 +44,6 @@ protected function createDefaultActivity(): AddLanguageEntry return $this->createActivity([]); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -100,10 +94,6 @@ public function testIsAllowedToPerformNeverTouchesRepositoryOrWriteClosures(): v $this->assertTrue($activity->isAllowedToPerform(6, ['module' => 'common', 'identifier' => 'foo'])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsEmptyOnes(): void { $calls = []; @@ -170,12 +160,7 @@ public function testAddsEntryForEachInstalledLanguageWithANonEmptyValueAndSkipsE } } - /** - * Regression test for the deliberate "not transactional" behaviour noted above perform()'s - * write loop: if replace_lang_entry() throws partway through (here: the 3rd of 3 languages), - * languages already written must remain written, and the exception must propagate rather than - * roll back. - */ + /** "Not transactional" is deliberate: languages written before the failing one stay written, the exception propagates. */ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEarlierLanguagesWritten(): void { $calls = []; @@ -215,10 +200,7 @@ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEar } catch (\RuntimeException $e) { $this->assertSame('simulated database error while writing "fr"', $e->getMessage()); - // "de" and "en" - written before the failing "fr" - remain - // written: perform() gives no atomicity guarantee across the - // write loop (see perform()'s own "Not transactional..." - // comment above the loop). + // "de" and "en" stay written: no atomicity across the write loop $this->assertSame( ['common', 'new_topic', 'de', 'Hallo', $calls['replace'][0][4], 'default-login'], $calls['replace'][0] @@ -230,18 +212,12 @@ public function testAThrowingReplaceLangEntryPartwayThroughTheWriteLoopLeavesEar $this->assertSame(['de', 'common', 'new_topic', 'Hallo'], $calls['cache'][0]); $this->assertSame(['en', 'common', 'new_topic', 'Hello'], $calls['cache'][1]); - // Exactly the 3 attempted calls - "fr" itself was attempted - // (and recorded before throwing) but never reached - // update_module_cache(). + // "fr" was attempted but never reached update_module_cache() $this->assertCount(3, $calls['replace']); $this->assertCount(2, $calls['cache']); } } - // ----------------------------------------------------------------- - // "de"/"en" mandatory, all-or-nothing (see getDescription()'s markdown) - // ----------------------------------------------------------------- - public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNothingForAnyLanguage(): void { $calls = []; @@ -269,8 +245,7 @@ public function testDeAndEnBothInstalledOneBlankRejectsWholeRequestAndWritesNoth } } - // Same de/en rule as the perform() test above, exercised via maybePerformAs() with a real UI - // Factory. + // Same de/en rule, exercised via maybePerformAs() with a real UI Factory. public function testMaybePerformAsRejectsWholeRequestAndWritesNothingWhenEnIsBlankWhileDeIsValid(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -388,10 +363,7 @@ public function testValueSurroundedByWhitespaceIsTrimmedBeforeBeingWritten(): vo $this->assertSame('Hallo Welt', $calls['cache'][0][3]); } - /** - * A non-string translation value (e.g. from a direct perform() caller bypassing - * normalizeParameters()) must be treated as empty, not coerced or crash. - */ + /** A non-string translation value (direct perform() caller) counts as empty. */ public function testNonStringTranslationValueIsTreatedAsEmptyAndSkippedRatherThanCoerced(): void { // 'fr' avoids the separate 'de'/'en' mandatory-value rule tested elsewhere. @@ -456,8 +428,7 @@ public function testUserLoginClosureIsCalledExactlyOnceWithTheGivenUsrIdRegardle $this->assertSame([99], $login_calls); } - // A missing usr_id is explicitly allowed (see perform()'s own docblock): a generic caller - // following the plain Activity contract never supplies one. + // a missing usr_id is allowed: a generic caller following the plain Activity contract never supplies one public function testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure(): void { $login_calls = []; @@ -498,10 +469,7 @@ public static function invalidPerformParametersProvider(): array 'non-string identifier' => [['module' => 'common', 'identifier' => [], 'translations' => [], 'usr_id' => 6]], 'missing translations' => [['module' => 'common', 'identifier' => 'foo', 'usr_id' => 6]], 'non-array translations' => [['module' => 'common', 'identifier' => 'foo', 'translations' => 'nope', 'usr_id' => 6]], - // A missing/null usr_id is explicitly ALLOWED (see - // testPerformWithoutUsrIdKeySucceedsWithAnAuthorlessAuditEntryAndNeverCallsUserLoginClosure() - // and testPerformSucceedsWhenUsrIdIsMissingOrNullGivenValidTranslations() below) - it must - // NOT appear here as an "invalid" case. Only a non-null usr_id of the wrong TYPE is invalid. + // a missing/null usr_id is valid (see the tests below); only a non-null one of the wrong type is invalid 'non-int usr_id' => [['module' => 'common', 'identifier' => 'foo', 'translations' => [], 'usr_id' => '6']], ]; } @@ -514,10 +482,7 @@ public function testPerformRejectsInvalidOrIncompleteParameters(array $parameter $this->createActivity(['de'])->perform($parameters); } - /** - * A missing or null usr_id, given otherwise-valid translations, must succeed - not throw (see - * perform()'s own docblock: only a non-null usr_id of the wrong type is rejected). - */ + /** A missing or null usr_id with otherwise valid translations succeeds. */ public static function validUsrIdMissingOrNullWithValidTranslationsProvider(): array { return [ @@ -554,16 +519,7 @@ public function testPerformRejectsNonArrayParameters(): void $this->createActivity(['de'])->perform('not-an-array'); } - // ----------------------------------------------------------------- - // Snapshot drift between getInputDescription() and perform() - see - // getInputDescription()'s own docblock. - // ----------------------------------------------------------------- - - /** - * The fake repository below returns different installed-language lists on its 1st - * (getInputDescription()) and 2nd (perform()) call, modeling this snapshot drift within one - * maybePerformAs() call. - */ + /** The fake repository returns different installed-language lists for getInputDescription() and perform(). */ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerformIsSkippedNotRejected(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -572,9 +528,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - 'fr' not installed yet, so no - // field/value for it exists in the form. - // 2nd call: perform() - 'fr' has since been installed. + // 1st call (getInputDescription()): 'fr' not installed yet; 2nd call (perform()): it is return $calls === 1 ? ['de', 'en'] : ['de', 'en', 'fr']; }); @@ -587,8 +541,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // No 'fr' key at all - getInputDescription() (1st call) never - // asked for one, since it did not yet know 'fr' was installed. + // no 'fr' key: the form was built before 'fr' was installed 'translations' => ['de' => 'Hallo', 'en' => 'Hello'], ]); @@ -600,8 +553,7 @@ public function testOptionalLanguageInstalledBetweenGetInputDescriptionAndPerfor $this->assertSame(2, $calls); } - // Fail-closed branch: "en" becomes mandatory only after getInputDescription() already built - // the form without asking for it - the whole request must be rejected, not partially skipped. + // fail-closed: "en" becomes mandatory only after the form was built without it public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWholeRequestRatherThanSkippingEn(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -610,9 +562,7 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - only "de" installed, "en" not - // yet, so no field/value for "en" exists in the form. - // 2nd call: perform() - "en" has since been installed. + // 1st call: only "de" installed; 2nd call: "en" too return $calls === 1 ? ['de'] : ['de', 'en']; }); @@ -628,16 +578,14 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // No 'en' key - getInputDescription() (1st call) never asked for - // one, since "en" was not yet installed when the form was built. + // no 'en' key: the form was built before "en" was installed 'translations' => ['de' => 'Hallo'], ]); $this->assertTrue($result->isError()); $error = $result->error(); $this->assertInstanceOf(InvalidInputException::class, $error); - // Only "en" is reported missing - "de" has a valid value, so the mandatory-value check - // doesn't flag it too. + // only "en" is reported missing $this->assertSame('A value is required for: en.', $error->getMessage()); // Nothing written for "de" either - fail-closed, not fail-partial. $this->assertArrayNotHasKey('replace', $calls_to_write_closures); @@ -646,13 +594,7 @@ public function testEnInstalledBetweenGetInputDescriptionAndPerformRejectsTheWho $this->assertSame(2, $calls); } - /** - * The opposite direction from the two tests above: an OPTIONAL language uninstalled between - * the two calls has a submitted value that is neither written nor rejected - it is silently - * dropped, unlike installing "de"/"en" (fail-closed) or installing an optional language - * (skipped normally). "fr" isolates this from the separate "de"/"en" mandatory rule tested - * elsewhere. - */ + /** An optional language uninstalled between the two calls: its submitted value is silently dropped. */ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerformIsSilentlyDroppedNotWritten(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -661,9 +603,7 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $calls = 0; $repository = new FakeInstalledLanguageRepository(static function () use (&$calls): array { $calls++; - // 1st call: getInputDescription() - "fr" still installed, so a - // field/value for it exists in the form. - // 2nd call: perform() - "fr" has since been uninstalled. + // 1st call: "fr" installed; 2nd call: uninstalled return $calls === 1 ? ['de', 'en', 'fr'] : ['de', 'en']; }); @@ -679,8 +619,7 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $result = $activity->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, [ 'module' => 'common', 'identifier' => 'new_topic', - // A valid value IS given for "fr" - getInputDescription() (1st - // call) still asked for it, since "fr" was installed at the time. + // a value for "fr" is given: the form still asked for it 'translations' => ['de' => 'Hallo', 'en' => 'Hello', 'fr' => 'Bonjour'], ]); @@ -700,10 +639,6 @@ public function testOptionalLanguageUninstalledBetweenGetInputDescriptionAndPerf $this->assertSame(2, $calls); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - public function testPermissionDeniedBeforePerformNeverCallsWriteClosuresAndReturnsA403ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -877,12 +812,7 @@ public function testMissingRawParametersKeyIsAResultError(): void $this->assertNotSame('', $result->error()->getMessage()); } - /** - * Both rejected by grind() itself, before normalizeParameters()/isAllowedToPerform() run, but - * for different reasons: a non-array 'translations' fails collectRawValues()'s type guard on - * the nested group; a non-string key can never match any of the group's string-keyed fields. - * Either way: an InvalidInputException, not an InvalidArgumentException. - */ + /** Rejected by grind() itself (non-array 'translations', non-string key) as InvalidInputException. */ public static function invalidRawParametersRejectedByGrindWithStringErrorProvider(): array { return [ @@ -959,11 +889,7 @@ public function testNormalizeParametersTrimsModuleAndIdentifierButKeepsTranslati $this->assertSame('new_topic', $calls['replace'][0][1]); } - /** - * An empty translations array is a legal input (e.g. a caller merely - * reserving the module/identifier pair without giving any value yet) - - * every installed language is then reported skipped, not rejected. - */ + /** An empty translations array is legal: every installed language is skipped, not rejected. */ public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLanguage(): void { // 'fr'/'it' avoid the separate 'de'/'en' mandatory-value rule tested elsewhere. @@ -983,16 +909,9 @@ public function testEmptyTranslationsArrayIsAcceptedAndSkipsEveryInstalledLangua $this->assertSame(['fr', 'it'], $result->value()['skipped_empty_language_keys']); } - // ----------------------------------------------------------------- - // getInputDescription() - // ----------------------------------------------------------------- - /** - * End-to-end companion to ActivityContractTestCase's reflection-based signature pin: actually - * defines a subclass overriding getInputDescription() with the plain interface signature, - * reproducing the PHP fatal error the old, buggy signature triggered (verified under PHP - * 8.5.4). A PHP variance fatal is a compile-time error, not a catchable \Throwable, hence - * #[RunInSeparateProcess] - so a regression fails this one test instead of crashing the suite. + * A PHP variance fatal is not catchable, hence a separate process: a regression fails this test instead of + * crashing the suite. */ #[RunInSeparateProcess] public function testSubclassOverridingGetInputDescriptionWithThePlainInterfaceSignatureDoesNotTriggerAFatalError(): void @@ -1113,10 +1032,6 @@ function (array $fields, string $label) use (&$section_calls, $module_text, $ide $this->assertFalse($fr_required); } - // ----------------------------------------------------------------- - // getOutputDescription() - // ----------------------------------------------------------------- - public function testOutputDescriptionDeclaresTheExpectedFourFields(): void { $activity = $this->createActivity(['de']); @@ -1134,10 +1049,6 @@ public function testOutputDescriptionDeclaresTheExpectedFourFields(): void ); } - // ----------------------------------------------------------------- - // Test helpers - // ----------------------------------------------------------------- - /** * @param array $calls */ @@ -1184,9 +1095,7 @@ private function createActivity( ?\Closure $update_module_cache = null, ?\Closure $user_login = null, ): AddLanguageEntry { - // Safe no-op defaults (never the real \ilObjLanguage-backed closures, which need global - // $DIC). user_login() runs unconditionally in perform(), so it always needs one too. - // Tests that must prove a closure is never called pass their own throwing closure instead. + // no-op defaults, as the real \ilObjLanguage closures need $DIC; user_login() always runs in perform() $replace_lang_entry ??= static fn( string $module, string $identifier, @@ -1223,12 +1132,7 @@ private function createActivity( } /** - * Minimal in-memory stand-in for InstalledLanguageRepository: - * InstalledLanguageDatabaseRepository (the real implementation) needs an - * actual database connection, which unit tests must never construct. Only - * getInstalledLanguages() is exercised by AddLanguageEntry; every other - * method is unreachable from it and simply throws if a test ever calls it by - * mistake. + * In-memory stand-in for InstalledLanguageRepository; only getInstalledLanguages() is reachable, the rest throws. */ final class FakeInstalledLanguageRepository implements InstalledLanguageRepository { diff --git a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php index 71cfddbc0717..0b4f3ce59d81 100644 --- a/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php +++ b/components/ILIAS/Language/tests/Activities/GrindsFormInputTest.php @@ -27,9 +27,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** - * GrindsFormInput in isolation from any concrete Activity, via GrindsFormInputTestHost. - */ class GrindsFormInputTest extends TestCase { use RealFieldsUiFactory; @@ -100,10 +97,7 @@ public static function checkboxWhitelistRejectsRawValueProvider(): array ]; } - /** - * Values outside the whitelist must become an InvalidInputException (code 400), which - * RendersActivityErrors shows to the user instead of logging an internal failure. - */ + /** Becomes an InvalidInputException (400), shown to the user instead of logged. */ #[DataProvider('checkboxWhitelistRejectsRawValueProvider')] public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_value): void { @@ -115,9 +109,7 @@ public function testGrindRejectsRawValueOutsideTheCheckboxWhitelist(mixed $raw_v $this->assertSame(400, $error->getCode()); } - /** - * Same conversion, reached via Text::isClientSideValueOk() instead of normalizeCheckboxRawValue(). - */ + /** Same conversion, reached via Text::isClientSideValueOk(). */ public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStringTextValueIntoInvalidInputException(): void { $text = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') @@ -135,9 +127,7 @@ public function testGrindConvertsAUiFrameworkInvalidArgumentExceptionFromANonStr $this->assertSame(400, $error->getCode()); } - /** - * A validation failure must read ": ", not Group::withInput()'s generic "ui_error_in_group". - */ + /** Must read ": ", not Group::withInput()'s generic "ui_error_in_group". */ public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidationFailure(): void { $language_key = $this->createRealFieldsUiFactory()->input()->field()->text('Language key', '') @@ -157,10 +147,7 @@ public function testGrindReportsAFieldAttributedInvalidInputExceptionOnValidatio $this->assertNotSame('language_key: ', $error->getMessage()); } - /** - * A raw array of strings (as sent by class.ilObjLanguageFolderGUI.php for 'language_keys') is joined into one - * comma-separated string. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings: joined with commas. */ public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSeparatedString(): void { $language_keys = $this->createRealFieldsUiFactory()->input()->field()->text('Language keys', '') @@ -175,9 +162,7 @@ public function testGrindJoinsAnArrayOfStringsRawValueForATextFieldIntoACommaSep $this->assertSame('de,fr', $result->value()['language_keys']); } - /** - * The outermost group does not enforce known keys: an unrelated top-level key is ignored. - */ + /** The outermost group does not enforce known keys. */ public function testGrindSilentlyIgnoresAnUnknownKeyAtTheTopLevelOfRawParameters(): void { $name = $this->createRealFieldsUiFactory()->input()->field()->text('Name', '') @@ -236,10 +221,7 @@ public static function nonArrayNonNullGroupRawValueProvider(): array ]; } - /** - * A group value that is neither null nor an array must be rejected; degrading it to an empty array would mask - * it as a misleading required-field error. - */ + /** Degrading it to an empty array would mask it as a misleading required-field error. */ #[DataProvider('nonArrayNonNullGroupRawValueProvider')] public function testGrindRejectsANonArrayNonNullRawValueForANestedGroupInsteadOfSilentlyDegradingToEmptyArray( mixed $raw_value diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index b60fe59259ad..f8368676d331 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -98,11 +98,7 @@ public function testMultipleLanguagesSeparatesNotInstalledFromAlreadyInstalledUn public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile(): void { - // Even a language that was not installed before must be classified - // by "has a customizing/local file", not "was newly installed" - a - // fresh language that already ships a local file is still more - // accurately described as "installed with custom file" than as a - // plain install. + // classified by "has a customizing/local file", not "was newly installed" $setup_language = $this->createSetupLanguageMock( [], ['de'], @@ -126,14 +122,7 @@ public function testNewLanguageWithCustomFileIsReportedAsInstalledWithLocalFile( $this->assertSame([], $result['not_installed_language_keys']); } - /** - * This is the central bugfix regression test: mode "install" used to - * unconditionally (re-)install and (re-)apply the customizing/local file - * of an already installed language on every call. It must now be a - * complete no-op instead - not even checkLanguageForInstallation may run - * for it, since (re-)applying a customizing file is exclusively mode - * "install_local"'s job now. - */ + /** Mode "install" on an installed language is a complete no-op: re-applying the local file is "install_local"'s job. */ public function testInstallModeIsNoOpForAlreadyInstalledLanguageEvenWithACustomFile(): void { $setup_language = $this->createSetupLanguageMock( @@ -172,10 +161,7 @@ public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage( $setup_language->expects($this->once()) ->method('insertLanguageForApplyingLocalChanges') ->with('de'); - // The object_data bookkeeping itself is delegated to - // ilSetupLanguage::registerInstalledLanguage() (shared with - // installLanguages()) - see ilSetupLanguageTest for coverage of the - // actual INSERT/UPDATE SQL it builds. + // object_data bookkeeping is delegated to ilSetupLanguage::registerInstalledLanguage() (see ilSetupLanguageTest) $setup_language->expects($this->once()) ->method('registerInstalledLanguage') ->with( @@ -196,13 +182,7 @@ public function testInstallLocalModeAppliesCustomFileToAlreadyInstalledLanguage( $this->assertSame([], $result['invalid_local_language_files']); } - /** - * "install_local" still runs its write methods for an already installed - * language even when no customizing/local file actually exists for it - * (there is simply nothing for them to write) - but the *result* must - * report this as "already installed", not as "installed with local - * file", since nothing observable actually changed. - */ + /** "install_local" without a local file is reported as "already installed": nothing changed. */ public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileIsReportedAsAlreadyInstalled(): void { $setup_language = $this->createSetupLanguageMock( @@ -229,11 +209,7 @@ public function testInstallLocalModeOnAlreadyInstalledLanguageWithoutCustomFileI $this->assertSame([], $result['not_installed_language_keys']); } - /** - * "install_local" on a language that is not installed at all must be a - * complete no-op - there is nothing installed yet to apply local - * changes on top of. - */ + /** "install_local" on a language that is not installed is a no-op. */ public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void { $setup_language = $this->createSetupLanguageMock([], [], []); @@ -254,12 +230,7 @@ public function testInstallLocalModeOnNotInstalledLanguageIsCompleteNoOp(): void $this->assertSame(['de'], $result['not_installed_language_keys']); } - /** - * When every requested language turns out to be a no-op (given the - * chosen mode and each language's install status), perform() must not - * even read the available/local languages or scan for invalid local - * files - there is nothing left to do that would need them. - */ + /** If every language is a no-op, perform() must not read the available/local languages or scan local files. */ public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEntirely(): void { $setup_language = $this->createMock(ilSetupLanguage::class); @@ -285,11 +256,7 @@ public function testAllRequestedLanguagesBeingNoOpsSkipsLanguageAndFileLookupsEn $this->assertSame([], $result['invalid_local_language_files']); } - /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and only the ones actually affected - * may trigger any write method - under mode "install". - */ + /** Every state in one request lands in its own bucket; only affected ones trigger a write (mode "install"). */ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBucket(): void { $setup_language = $this->createSetupLanguageMock( @@ -309,8 +276,7 @@ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBu $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); $setup_language->expects($this->once()) ->method('getInvalidLocalLanguageFiles') - // Only the two languages actually being (fully) installed are - // relevant - the already-installed 'de' is untouched. + // the already-installed 'de' is untouched ->with(['en', 'fr']); $result = $this->createActivity($setup_language)->perform([ @@ -324,12 +290,7 @@ public function testBulkRequestUnderInstallModePartitionsEachKeyIntoTheCorrectBu $this->assertSame([], $result['not_installed_language_keys']); } - /** - * The same idea, but under mode "install_local": no key may ever trigger - * checkLanguageForInstallation/flushLanguageForInstallation/ - * insertLanguageForInstallation - only insertLanguageForApplyingLocalChanges, - * and only for the already-installed keys. - */ + /** Mode "install_local": only insertLanguageForApplyingLocalChanges, only for already-installed keys. */ public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorrectBucket(): void { $setup_language = $this->createSetupLanguageMock( @@ -349,8 +310,7 @@ public function testBulkRequestUnderInstallLocalModePartitionsEachKeyIntoTheCorr $setup_language->expects($this->exactly(2))->method('registerInstalledLanguage'); $setup_language->expects($this->once()) ->method('getInvalidLocalLanguageFiles') - // Only the two already-installed languages are affected - the - // not-installed 'fr' is left untouched. + // the not-installed 'fr' is left untouched ->with(['de', 'en']); $result = $this->createActivity($setup_language)->perform([ @@ -555,12 +515,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see InstallLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -568,19 +523,7 @@ public function testNonArrayParametersAreRejected(): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform('not-an-array'); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndInstallsEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 7bf7bf7bb6f2..18c9fe367a75 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -168,9 +168,6 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm } } -/** - * LanguageActivity whose getInputDescription() always throws; every other abstract method throws too. - */ final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { public function __construct( diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index 14bea4c4c26f..f13d58bb738f 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -24,14 +24,7 @@ use PHPUnit\Framework\Attributes\DataProvider; /** - * RemoveLocalLanguageChanges has no ilSetupLanguage collaborator (unlike - * InstallLanguage/UpdateLanguage) - instead it resolves language keys to - * objects via two closures, $lng_objects and $obj_language_factory. The - * object returned by $obj_language_factory must behave like \ilObjLanguage - * (isInstalled(), removeLocalChanges()), but \ilObjLanguage itself cannot be - * constructed or meaningfully mocked in a unit test (its constructor needs - * the global $DIC) - so tests use a small fake object instead, injected - * through $obj_language_factory. + * \ilObjLanguage cannot be constructed in a unit test (its constructor needs $DIC), so a fake object stands in. */ class RemoveLocalLanguageChangesTest extends ActivityWithPerformResultContractTestCase { @@ -50,12 +43,7 @@ protected function validPerformParameters(): array } /** - * @param array $language_objects - * Keyed by language key; each entry becomes a fake object with - * the given isInstalled() answer (default true) and - * removeLocalChanges() return value (default true) reachable - * from the corresponding obj_id (assigned in iteration order, - * starting at 1). + * @param array $language_objects keyed by language key * @return array{0: array, 1: \Closure, 2: \Closure} * [obj id => fake object, lng_objects closure, obj_language_factory closure] */ @@ -80,14 +68,6 @@ private function buildFakeLanguageWorld(array $language_objects): array return [$fakes_by_obj_id, $lng_objects, $obj_language_factory]; } - // ----------------------------------------------------------------- - // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" - // objects sharing the same title must reject the whole request for - // that title rather than silently resolving to whichever one happened - // to be enumerated last - see UninstallLanguage's identical guard and - // UninstallLanguageTest's equivalent tests for the same reasoning. - // ----------------------------------------------------------------- - public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void { $lng_objects = static fn(): array => [ @@ -112,37 +92,7 @@ public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactory } } - /** - * Regression test: the AmbiguousLanguageTitleException message embeds - * the ambiguous title RAW/unescaped - escaping HTML-significant - * characters for safe display is deliberately no longer this domain - * layer's job (see RemoveLocalLanguageChanges::perform()'s own comment - * above the throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - * - * NOTE (found while adding format validation coverage for - * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used - * an HTML/XSS payload ('&"quoted"') as BOTH the - * ambiguous objects' title AND the requested 'language_keys' value - - * $language_keys must equal an object's title verbatim for - * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted - * (perform() intersects the requested, already-parsed $language_keys - * against that map's keys). Since toLanguageKeyList() now rejects any - * value that is not exactly two lowercase ASCII letters BEFORE that - * intersection is even computed, such a value now throws - * InvalidInputException immediately and never reaches - * AmbiguousLanguageTitleException at all - this test would otherwise - * fail with that InvalidInputException instead of the expected - * AmbiguousLanguageTitleException. The dangerous-title scenario is - * therefore no longer reachable through perform()'s public parameter - * surface at all (a real, if incidental, hardening side effect of the - * new format check) - only a title that happens to already be a - * plausible two-letter language key can ever become "ambiguous" via a - * real request. The title/key below is changed accordingly; the - * "no HTML-escaping happens" assertions are kept for continuity, even - * though a two-letter title trivially satisfies them. - */ + /** The ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void { $ambiguous_title = 'de'; @@ -166,11 +116,7 @@ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle() } } - /** - * Via maybePerformAs(), the same ambiguous-title \RuntimeException must - * surface as a Result\Error, not propagate as an uncaught exception - - * and, again, the object factory must never be reached for it. - */ + /** Via maybePerformAs() the ambiguity surfaces as Result\Error and the object factory is never reached. */ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -194,12 +140,7 @@ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCal $this->assertSame([], $factory_calls); } - /** - * An ambiguous title must not poison unrelated, unambiguous titles in - * the same $lng_objects() world/request - a genuinely unique language - * key coexisting with an ambiguous one must still resolve and have its - * local changes removed normally. - */ + /** An ambiguous title does not poison unrelated, unambiguous ones. */ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void { $lng_objects = static fn(): array => [ @@ -224,20 +165,7 @@ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInThe $this->assertSame(1, $fr->removeLocalChangesCallCount()); } - /** - * Regression test for the ambiguity check being moved OUT of the - * per-key foreach loop and performed for ALL requested keys BEFORE any - * removeLocalChanges() call (see the comment directly above that check - * in perform(): "Checked upfront, for all requested keys at once, so a - * request naming both an unambiguous and an ambiguous key never - * changes the unambiguous one before rejecting the whole call."). A - * request naming an unambiguous key ('de') FIRST and an ambiguous one - * ('fr') SECOND must reject the whole request and must NOT have already - * changed 'de' by the time 'fr' is reached - before this fix, the - * ambiguity check ran inline inside the loop, so 'de' (processed first) - * would already have had its local changes removed for real before the - * loop reached the ambiguous 'fr' and threw. - */ + /** The ambiguity check covers all requested keys before any change: 'de' must not be changed when 'fr' is ambiguous. */ public function testAnUnambiguousKeyBeforeAnAmbiguousOneNeverHasLocalChangesRemovedOnceTheWholeRequestIsRejected(): void { $de = new FakeRemoveLocalLanguageChangesObject(is_installed: true); @@ -260,25 +188,18 @@ public function testAnUnambiguousKeyBeforeAnAmbiguousOneNeverHasLocalChangesRemo $activity = $this->createActivity($lng_objects, $obj_language_factory); try { - // 'de' listed BEFORE the ambiguous 'fr' on purpose - see the - // "Checked upfront, for all requested keys at once" comment - // above the ambiguity check in perform(). + // 'de' first on purpose $activity->perform(['language_keys' => 'de,fr']); $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); } catch (AmbiguousLanguageTitleException $e) { $this->assertStringContainsString('"fr"', $e->getMessage()); - // 'de' was never resolved to a factory call, let alone changed - - // the ambiguity check ran BEFORE the per-key loop. + // 'de' was never resolved, the check ran before the per-key loop $this->assertSame([], $factory_calls); $this->assertSame(0, $de->removeLocalChangesCallCount()); } } - /** - * Same regression, exercised end-to-end through maybePerformAs(): the - * Result\Error must surface without 'de' having had its local changes - * removed. - */ + /** Same, end-to-end through maybePerformAs(). */ public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -307,10 +228,6 @@ public function testMaybePerformAsNeverRemovesLocalChangesForAnUnambiguousKeyWhe $this->assertSame(0, $de->removeLocalChangesCallCount()); } - // ----------------------------------------------------------------- - // isAllowedToPerform() - // ----------------------------------------------------------------- - public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfiguredRefId(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -344,12 +261,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch $lng_objects or $obj_language_factory (and therefore never call - * removeLocalChanges()) - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no object access, no removeLocalChanges(). */ public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): void { $lng_objects = static function (): array { @@ -367,10 +279,6 @@ public function testIsAllowedToPerformNeverTouchesLngObjectsOrObjectFactory(): v $this->assertTrue($activity->isAllowedToPerform(6, ['language_keys' => 'de'])); } - // ----------------------------------------------------------------- - // perform() - // ----------------------------------------------------------------- - public function testInstalledLanguageWithValidFileHasItsLocalChangesRemoved(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -403,15 +311,7 @@ public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalle $this->assertSame(0, $fakes[1]->removeLocalChangesCallCount()); } - /** - * Regression test: removeLocalChanges() itself is the authority on - * whether the language file was valid, not merely the outer - * isInstalled() guard. A language that passes isInstalled() (so - * perform() does call removeLocalChanges()) but whose - * removeLocalChanges() nonetheless returns false (invalid language - * file) must be reported as invalid_language_file_keys, never as - * removed_local_changes_language_keys or not_installed_language_keys. - */ + /** removeLocalChanges() returning false (invalid language file) is reported as invalid_language_file_keys. */ public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInvalidLanguageFile(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -428,12 +328,7 @@ public function testInstalledLanguageWhereRemoveLocalChangesFailsIsReportedAsInv $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } - /** - * A language key with no corresponding "lng" object at all must be - * reported as not installed - and, crucially, $obj_language_factory - * must never even be invoked for it (there is no obj_id to construct it - * from in the first place). - */ + /** A key without a "lng" object is not installed, and the object factory is never invoked for it. */ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingTheObjectFactory(): void { $lng_objects = static fn(): array => []; @@ -453,13 +348,7 @@ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingThe $this->assertSame([], $factory_calls); } - /** - * A single request can carry languages in every possible state at once; - * each must land in its own bucket, and removeLocalChanges() must only - * ever be invoked for the two genuinely installed languages (regardless - * of whether their file turns out valid or not) - never for the - * not-installed or unknown ones. - */ + /** Every state in one request lands in its own bucket; removeLocalChanges() only runs for the two installed ones. */ public function testMixedBulkRequestPartitionsEachKeyIntoTheCorrectBucket(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -533,20 +422,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see RemoveLocalLanguageChanges::perform(), ~line 143): this check was - * switched from a plain \InvalidArgumentException to the concrete - * InvalidInputException, to be consistent with AddLanguageEntry, - * SetLanguageDetectionEnabled and SetLanguageTranslationEnabled, which - * already used it. The docblock/comment that used to justify the - * opposite ("deliberately NOT an InvalidInputException") is stale and - * has been removed - this test now pins the CURRENT, more specific - * exception class, not just \InvalidArgumentException (which - * InvalidInputException extends, and against which this assertion would - * therefore have kept passing silently even after the behaviour change - - * see RendersActivityErrors, which treats 4xx specially). - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException, which would pass silently. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -554,17 +430,7 @@ public function testNonArrayParametersAreRejected(): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform('not-an-array'); } - // ----------------------------------------------------------------- - // maybePerformAs() - // ----------------------------------------------------------------- - - /** - * Regression test analogous to - * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall(): - * a denied permission must short-circuit before perform() ever runs - - * $lng_objects/$obj_language_factory (and therefore removeLocalChanges()) - * must never be touched. - */ + /** Denied permission short-circuits before perform(): no object is touched. */ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -610,19 +476,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(1, $fakes[1]->removeLocalChangesCallCount()); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalChangesForEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -642,12 +496,7 @@ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndRemovesLocalCha $this->assertSame(1, $fakes[2]->removeLocalChangesCallCount()); } - /** - * A Throwable raised from within perform() (e.g. a validation failure - * on the normalized parameters, or any other unexpected failure) must - * be turned into a Result\Error rather than propagating - this is the - * whole point of maybePerformAs() wrapping perform() in a try/catch. - */ + /** A Throwable from perform() becomes a Result\Error. */ public function testThrowableFromPerformIsTurnedIntoAResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -668,16 +517,7 @@ public function testThrowableFromPerformIsTurnedIntoAResultError(): void $this->assertSame(400, $result->error()->getCode()); } - /** - * A completely missing 'language_keys' key is now caught by grind() - * itself (see GrindsFormInput): the required Text field receives a - * blank raw value and fails its own required-field constraint before - * normalizeParameters()/isAllowedToPerform() are ever reached - so the - * rejection surfaces as a Result\Error carrying the UI field's own - * (string) validation message, not an InvalidArgumentException thrown - * by normalizeParameters(). Either way, the rbac system must never be - * touched (validation happens first). - */ + /** A missing 'language_keys' fails the required Text field in grind(), before rbac is touched. */ public function testMissingLanguageKeysParameterViaMaybePerformAsIsAResultErrorAndNeverChecksPermission(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -711,23 +551,14 @@ private function createActivity( } /** - * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or - * meaningfully mocked in a unit test (its constructor requires the global - * $DIC), so tests substitute this fake via RemoveLocalLanguageChanges's - * $obj_language_factory closure instead. Counts removeLocalChanges() - * invocations so tests can assert it was never called for a bucket other - * than "removed"/"invalid". + * Stand-in for \ilObjLanguage (see above); counts removeLocalChanges() calls. */ final class FakeRemoveLocalLanguageChangesObject { private int $remove_local_changes_calls = 0; /** - * @param bool $remove_local_changes_return_value What removeLocalChanges() - * returns - true mimics a real, successful removal of local - * changes; false mimics \ilObjLanguage::removeLocalChanges() - * rejecting the call because the underlying language file failed - * check() (isInstalled() having already let it through). + * @param bool $remove_local_changes_return_value false mimics a language file failing check() after isInstalled() passed */ public function __construct( private readonly bool $is_installed, diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index ab674bd6425c..8884ec3a7e5a 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -60,12 +60,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['enabled' => true])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch the Setting collaborator (and therefore never write - * "lang_detection") - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no Setting access. */ public function testIsAllowedToPerformNeverTouchesSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -121,12 +116,7 @@ public function testPerformRejectsNonArrayParameters(): void $this->createActivity(repository: $repository)->perform('not-an-array'); } - /** - * Boundary/type-juggling regression: perform() must require a strict - * bool for 'enabled' and must never silently cast a truthy/falsy - * look-alike (string "1"/"0"/"true", int 1, etc.) - unlike PHP's own - * loose comparison rules, which would happily accept any of these. - */ + /** perform() requires a strict bool for 'enabled'; truthy/falsy look-alikes ("1", 1, ...) are rejected. */ #[DataProvider('nonBooleanEnabledValuesProvider')] public function testPerformRejectsNonStrictBooleanEnabledValues(mixed $value): void { @@ -169,19 +159,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul $this->assertSame(['enabled' => true], $result->value()); } - /** - * Core security regression test: before this extraction, - * enableLanguageDetectionObject()/disableLanguageDetectionObject() - * performed no RBAC check of their own at all - a request forged - * directly against the corresponding ilCtrl - * commands by a user with only "read" access on the language folder - * would still have flipped "lang_detection". isAllowedToPerform() now - * enforces a "write" check, and this must actually cause - * maybePerformAs() to reject the request (Result\Error) *and* leave the - * Setting collaborator completely untouched - a permission check that - * merely rejected the outcome without preventing the side effect would - * not close the gap at all. - */ + /** The permission check is a real gate: a denied "write" yields a Result\Error and leaves the Setting untouched. */ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -219,17 +197,7 @@ public function testMaybePerformAsRejectsAnEnabledValueOutsideTheCheckboxWhiteli $this->assertSame(400, $result->error()->getCode()); } - /** - * The 'enabled' Checkbox field is not required, so - now that - * maybePerformAs() actually grinds $raw_parameters through - * getInputDescription() (see GrindsFormInput) - a completely missing - * 'enabled' key is no longer rejected: it defaults to false, exactly - * like an unchecked, and therefore never submitted, HTML checkbox - * would. This is a deliberate behavioural change from the old - * (pre-grinding) normalizeParameters(), which used to reject a missing - * key outright. Unlike before, the request now actually reaches - * isAllowedToPerform()/perform(). - */ + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -266,14 +234,7 @@ public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array ]; } - /** - * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): - * unlike perform() itself (which still requires a strict bool, see - * testPerformRejectsNonStrictBooleanEnabledValues), maybePerformAs() - * now accepts the common primitive representations of "true" a generic - * (non-HTML) caller might reasonably send, and writes the setting - * accordingly. - */ + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsAndWritesEnabledTrue(mixed $value): void { @@ -290,9 +251,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyRepresentationsA $this->assertSame(['enabled' => true], $result->value()); } - /** - * Same tolerance for the common representations of "false". - */ + /** Same for "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyRepresentationsAndWritesEnabledFalse(mixed $value): void { diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index b04d1eca872c..64e49d6e30a3 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -61,12 +61,7 @@ public function testIsAllowedToPerformReturnsFalseWhenRbacDenies(): void $this->assertFalse($activity->isAllowedToPerform(6, ['language_key' => 'de', 'enabled' => true])); } - /** - * isAllowedToPerform() must be a pure permission check: it must never - * touch the Setting collaborator (and therefore never read/write - * "lang_translate_*") - unlike perform(), which is the only place - * allowed to have side effects. - */ + /** isAllowedToPerform() is a pure permission check: no Setting access. */ public function testIsAllowedToPerformNeverTouchesSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -121,12 +116,7 @@ public function testPerformWritesOnlyWhenTheRequestedValueDiffersFromTheStoredOn ); } - /** - * get() must be called with the correct per-language key - * ("lang_translate_" . language_key) and the documented default '0' - - * a mutant that drops the default, or concatenates the key in the wrong - * order/without the separator, must be caught here. - */ + /** Pins the key "lang_translate_" and the default '0'. */ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -136,12 +126,7 @@ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero() $this->createActivity(repository: $repository)->perform(['language_key' => 'fr', 'enabled' => false]); } - /** - * language_key must be trimmed before being used to build the setting - * key and before being returned - mirroring toNonEmptyString()'s - * behaviour used by normalizeParameters(), but exercised here directly - * through perform() since perform() re-implements its own trim(). - */ + /** perform() re-implements its own trim() of language_key. */ public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -206,13 +191,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul ); } - /** - * Permission check regression: a denied "write" access must both yield - * a Result\Error *and* leave the Setting collaborator completely - * untouched (no read, no write) - a permission check that merely - * rejected the outcome without preventing the read/write would not be a - * real gate. - */ + /** A denied "write" yields a Result\Error and leaves the Setting untouched. */ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouchesSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -236,12 +215,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche $this->assertSame(403, $result->error()->getCode()); } - /** - * A Throwable raised from within perform() (e.g. the Setting - * collaborator failing to write) must be turned into a Result\Error - * rather than propagating - this is the whole point of maybePerformAs() - * wrapping perform() in a try/catch. - */ + /** A Throwable from perform() becomes a Result\Error. */ public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -260,16 +234,7 @@ public function testDefectFromWithinPerformIsMaskedAsA500ResultError(): void $this->assertSame('database write failed', $error->getPrevious()?->getMessage()); } - /** - * A completely missing 'language_key' key is caught by grind() itself - * (see GrindsFormInput): the required Text field receives a blank raw - * value and fails its own required-field constraint before - * normalizeParameters()/isAllowedToPerform() are ever reached - so the - * rejection surfaces as a Result\Error carrying a field-attributed - * InvalidInputException (see GrindsFormInput::describeInputError()), not - * an InvalidArgumentException. The rbac system must never be touched - * either way (validation happens first). - */ + /** A missing 'language_key' fails the required Text field in grind(), before rbac is touched. */ public function testMaybePerformAsWithMissingRawParametersKeysIsAResultErrorAndNeverChecksPermission(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -338,16 +303,7 @@ public function testMaybePerformAsRejectsInvalidRawParametersAsResultErrorWithou $this->assertSame(400, $result->error()->getCode()); } - /** - * The 'enabled' Checkbox field is not required, so a completely - * missing 'enabled' key is no longer rejected by grind() - it defaults - * to false, exactly like an unchecked, and therefore never submitted, - * HTML checkbox would (same behaviour as - * SetLanguageDetectionEnabledTest's equivalent regression test). - * Unlike a rejection at grind level, this reaches isAllowedToPerform()/ - * perform() and can still write the setting if that changes the - * currently stored value. - */ + /** A missing 'enabled' defaults to false like an unchecked HTML checkbox, and still reaches perform(). */ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPerformsTheWrite(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -367,15 +323,7 @@ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPe ); } - /** - * A whitespace-only language_key value satisfies the Text field's own - * required-check (min length 1 on the raw string, before trimming), so - * grind() accepts it and the request DOES reach isAllowedToPerform() - - * unlike the fully-missing-key case above. It is perform() itself - * (via its own trim() check, see - * testPerformRejectsInvalidOrIncompleteParametersWithoutTouchingSettings) - * that ultimately rejects it. - */ + /** A whitespace-only language_key passes grind()'s required-check, so perform() rejects it after the permission check. */ public function testMaybePerformAsRejectsWhitespaceOnlyLanguageKeyAfterThePermissionCheck(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -413,12 +361,7 @@ public static function tolerantlyAcceptedFalsyEnabledValuesProvider(): array ]; } - /** - * Grinding tolerance (see GrindsFormInput::normalizeCheckboxRawValue()): - * unlike perform() itself (which still requires a strict bool), - * maybePerformAs() now accepts the common primitive representations of - * "true" a generic (non-HTML) caller might reasonably send. - */ + /** Unlike perform(), maybePerformAs() accepts common primitive representations of "true". */ #[DataProvider('tolerantlyAcceptedTruthyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresentations(mixed $value): void { @@ -439,9 +382,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent ); } - /** - * Same tolerance for the common representations of "false". - */ + /** Same for "false". */ #[DataProvider('tolerantlyAcceptedFalsyEnabledValuesProvider')] public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresentations(mixed $value): void { @@ -462,12 +403,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta ); } - /** - * Regression test for Punkt 4 of this component's grinding task: unlike - * before, an unknown/not-installed language_key is now rejected by - * perform() itself, for direct perform()/isAllowedToPerform() callers - * too - not only via maybePerformAs(). - */ + /** An unknown language_key is rejected by perform() itself, for direct callers too. */ public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -480,15 +416,7 @@ public function testPerformRejectsUnknownLanguageKeyWithoutTouchingSettings(): v ->perform(['language_key' => 'xx', 'enabled' => true]); } - /** - * Regression test: the "Unknown language key" InvalidInputException - * message embeds the offending language_key RAW/unescaped - escaping - * HTML-significant characters for safe display is deliberately no - * longer this domain layer's job (see perform()'s own comment above the - * throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - */ + /** The message embeds the key raw: escaping is RendersActivityErrors' job. */ public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescapedKey(): void { $repository = $this->createMock(RepositoryProvider::class); @@ -508,14 +436,7 @@ public function testPerformRejectsUnknownLanguageKeyMessageContainsTheRawUnescap } } - /** - * The same rejection, exercised end-to-end through maybePerformAs(): - * the unknown language_key passes grind() (any non-blank string - * satisfies the Text field's own required-check) and the permission - * check (isAllowedToPerform() does not know about installed languages - * either), so it is perform() itself that rejects it - and the Setting - * collaborator must never be touched. - */ + /** Same rejection end-to-end: it passes grind() and the permission check, perform() rejects it. */ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckAndNeverTouchesSettings(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -533,14 +454,7 @@ public function testMaybePerformAsRejectsUnknownLanguageKeyAfterPermissionCheckA $this->assertSame(400, $result->error()->getCode()); } - /** - * normalizeParameters()'s toNonEmptyString() must trim language_key - * before it reaches perform() - exercised end-to-end through - * maybePerformAs() so a caller submitting e.g. a copy-pasted language - * key with surrounding whitespace still resolves to the same setting - * key as perform() itself would (see - * testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue). - */ + /** A language_key with surrounding whitespace resolves to the same setting key as via perform(). */ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -596,14 +510,7 @@ public function testInputDescriptionBuildsAGroupWithARequiredLanguageKeyTextFiel $this->assertSame($group, $activity->getInputDescription($field)); } - /** - * Uses a real DescriptionFactory (like AddLanguageEntryTest's own - * output-description test) rather than a mocked one, so that - * getFields()'s *actual* iteration order is asserted - a mocked - * object()->with([...]) call would accept the three fields in any - * order (PHPUnit's array comparison is order-independent), silently - * missing a mutation that swapped e.g. 'enabled' and 'changed'. - */ + /** Real DescriptionFactory: a mocked with([...]) would accept the fields in any order. */ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): void { $activity = $this->createActivity(); diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 612a964ed652..3f01aec42996 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -44,13 +44,6 @@ protected function validPerformParameters(): array return ['language_keys' => 'de']; } - // ----------------------------------------------------------------- - // maybePerformAs() turning a \Throwable from perform() into a - // Result\Error (m5) - here, the new ambiguous-title \RuntimeException - // from resolveObjIdsByLanguageKey() (see below) doubles as the concrete - // \Throwable this class' perform() can actually raise. - // ----------------------------------------------------------------- - public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePerformAs(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -71,13 +64,6 @@ public function testThrowableFromWithinPerformIsTurnedIntoAResultErrorByMaybePer $this->assertInstanceOf(\RuntimeException::class, $result->error()); } - // ----------------------------------------------------------------- - // resolveObjIdsByLanguageKey() ambiguous-title guard (m1): two "lng" - // objects sharing the same title must reject the whole request for - // that title rather than silently resolving to whichever one happened - // to be enumerated last. - // ----------------------------------------------------------------- - public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactoryForIt(): void { $lng_objects = static fn(): array => [ @@ -102,37 +88,7 @@ public function testAmbiguousTitleRejectsTheRequestAndNeverCallsTheObjectFactory } } - /** - * Regression test: the AmbiguousLanguageTitleException message embeds - * the ambiguous title RAW/unescaped - escaping HTML-significant - * characters for safe display is deliberately no longer this domain - * layer's job (see UninstallLanguage::perform()'s own docblock/comment - * above the throw), only \ILIAS\Language\RendersActivityErrors:: - * activityErrorMessage()'s. If this message were pre-escaped here, it - * would end up double-escaped once it reaches that single seam. - * - * NOTE (found while adding format validation coverage for - * ParsesLanguageKeyList::toLanguageKeyList()): this test originally used - * an HTML/XSS payload ('&"quoted"') as BOTH the - * ambiguous objects' title AND the requested 'language_keys' value - - * $language_keys must equal an object's title verbatim for - * resolveObjIdsByLanguageKey()'s ambiguity map to ever be consulted - * (perform() intersects the requested, already-parsed $language_keys - * against that map's keys). Since toLanguageKeyList() now rejects any - * value that is not exactly two lowercase ASCII letters BEFORE that - * intersection is even computed, such a value now throws - * InvalidInputException immediately and never reaches - * AmbiguousLanguageTitleException at all - this test would otherwise - * fail with that InvalidInputException instead of the expected - * AmbiguousLanguageTitleException. The dangerous-title scenario is - * therefore no longer reachable through perform()'s public parameter - * surface at all (a real, if incidental, hardening side effect of the - * new format check) - only a title that happens to already be a - * plausible two-letter language key can ever become "ambiguous" via a - * real request. The title/key below is changed accordingly; the - * "no HTML-escaping happens" assertions are kept for continuity, even - * though a two-letter title trivially satisfies them. - */ + /** The ambiguous title is embedded raw: escaping is RendersActivityErrors' job, doing it here would double-escape. */ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle(): void { $ambiguous_title = 'de'; @@ -156,11 +112,7 @@ public function testAmbiguousTitleExceptionMessageContainsTheRawUnescapedTitle() } } - /** - * Via maybePerformAs(), the same ambiguous-title \RuntimeException must - * surface as a Result\Error, not propagate as an uncaught exception - - * and, again, the object factory must never be reached for it. - */ + /** Via maybePerformAs() the ambiguity surfaces as Result\Error and the object factory is never reached. */ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCallsTheObjectFactory(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -184,12 +136,7 @@ public function testAmbiguousTitleViaMaybePerformAsReturnsResultErrorAndNeverCal $this->assertSame([], $factory_calls); } - /** - * An ambiguous title must not poison unrelated, unambiguous titles in - * the same $lng_objects() world/request - a genuinely unique language - * key coexisting with an ambiguous one must still resolve and uninstall - * normally. - */ + /** An ambiguous title does not poison unrelated, unambiguous ones. */ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInTheSameLngObjectsList(): void { $lng_objects = static fn(): array => [ @@ -214,21 +161,7 @@ public function testUnambiguousLanguageKeyStillWorksAlongsideAnAmbiguousOneInThe $this->assertSame(1, $fr->uninstallCallCount()); } - /** - * Regression test for the ambiguity check being moved OUT of the - * per-key foreach loop and performed for ALL requested keys BEFORE any - * uninstall() call (see the comment directly above the - * AmbiguousLanguageTitleException throw in UninstallLanguage::perform(): - * "Checked upfront, for all requested keys at once, so a request naming - * both an unambiguous and an ambiguous key never uninstalls the - * unambiguous one before rejecting the whole call"). A request naming - * an unambiguous key ('de') FIRST and an ambiguous one ('fr') SECOND - * must reject the whole request and must NOT have already uninstalled - * 'de' by the time 'fr' is reached - before this fix, the ambiguity - * check ran inline inside the loop, so 'de' (processed first) would - * already have been uninstalled for real before the loop reached the - * ambiguous 'fr' and threw. - */ + /** The ambiguity check covers all requested keys before any uninstall: 'de' must stay installed when 'fr' is ambiguous. */ public function testAnUnambiguousKeyBeforeAnAmbiguousOneIsNeverUninstalledOnceTheWholeRequestIsRejected(): void { $de = new FakeLanguageObject(is_system_language: false, is_user_language: false, is_installed: true); @@ -251,26 +184,18 @@ public function testAnUnambiguousKeyBeforeAnAmbiguousOneIsNeverUninstalledOnceTh $activity = $this->createActivity($lng_objects, $obj_language_factory); try { - // 'de' listed BEFORE the ambiguous 'fr' on purpose - this is the - // exact ordering the regression above (see this test's own - // docblock) requires: were the ambiguity check still inline in - // the per-key loop, 'de' being processed first would already - // have been uninstalled before 'fr' was ever reached. + // 'de' first on purpose $activity->perform(['language_keys' => 'de,fr']); $this->fail('Expected an AmbiguousLanguageTitleException to be thrown.'); } catch (AmbiguousLanguageTitleException $e) { $this->assertStringContainsString('"fr"', $e->getMessage()); - // 'de' was never resolved to a factory call, let alone - // uninstalled - the ambiguity check ran BEFORE the per-key loop. + // 'de' was never resolved, the check ran before the per-key loop $this->assertSame([], $factory_calls); $this->assertSame(0, $de->uninstallCallCount()); } } - /** - * Same regression, exercised end-to-end through maybePerformAs(): the - * Result\Error must surface without 'de' having been uninstalled. - */ + /** Same, end-to-end through maybePerformAs(). */ public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequestedKeyIsAmbiguous(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -300,12 +225,7 @@ public function testMaybePerformAsNeverUninstallsAnUnambiguousKeyWhenAnotherRequ } /** - * @param array $language_objects - * Keyed by language key; each entry becomes a fake object with - * the given isSystemLanguage()/isUserLanguage()/isInstalled() - * answers (default false/false/true) and uninstall() return - * value (default 'uninstalled') reachable from the - * corresponding obj_id (assigned in iteration order, starting at 1). + * @param array $language_objects keyed by language key * @return array{0: array, 1: \Closure, 2: \Closure} * [obj id => fake object, lng_objects closure, obj_language_factory closure] */ @@ -383,12 +303,7 @@ public function testLanguageCurrentlyInUseIsNeverUninstalled(): void $this->assertSame(0, $fakes[1]->uninstallCallCount()); } - /** - * isSystemLanguage() takes priority: a language that is somehow flagged - * as both the system language and the language currently in use must be - * reported as the system language, not as "in use" - the two branches - * are checked in that order in perform(). - */ + /** isSystemLanguage() takes priority over "in use" when both flags are set. */ public function testSystemLanguageTakesPriorityOverUserLanguage(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -420,17 +335,7 @@ public function testExistingButNotInstalledLanguageObjectIsReportedAsNotInstalle $this->assertSame(0, $fakes[1]->uninstallCallCount()); } - /** - * Regression test: uninstall() itself is the authority on whether the - * uninstallation actually happened, not merely the three outer guards - * (isSystemLanguage()/isUserLanguage()/isInstalled()). A language that - * passes all three guards (so perform() does call uninstall()) but - * whose uninstall() nonetheless returns '' (e.g. because a future, - * additional internal guard inside \ilObjLanguage::uninstall() itself - * rejects it) must be reported as not_installed_language_keys, never - * as uninstalled_language_keys - anything else would misreport a - * rejected uninstallation as a success. - */ + /** uninstall() returning '' is the authority: reported as not_installed_language_keys, never as uninstalled. */ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled(): void { [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ @@ -449,12 +354,7 @@ public function testLanguageWhereUninstallItselfRejectsIsReportedAsNotInstalled( $this->assertSame(1, $fakes[1]->uninstallCallCount()); } - /** - * A language key with no corresponding "lng" object at all must be - * reported as not installed - and, crucially, $obj_language_factory - * must never even be invoked for it (there is no obj_id to construct it - * from in the first place). - */ + /** A key without a "lng" object is not installed, and the object factory is never invoked for it. */ public function testUnknownLanguageKeyIsReportedAsNotInstalledWithoutTouchingTheObjectFactory(): void { $lng_objects = static fn(): array => []; @@ -538,14 +438,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity(static fn(): array => [], static fn(int $id) => null)->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see UninstallLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends - since perform() was switched - * from a plain \InvalidArgumentException to InvalidInputException for - * this check, matching every other Activity in this component. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -629,21 +522,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(1, $fakes[1]->uninstallCallCount()); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind(), via - * collectRawValues()'s call to joinListOfStringsRawValue() (see that - * method's own docblock: "Returns $raw_value joined into a single - * comma-separated string if every one of its items is a string; - * otherwise returns $raw_value unchanged, in whatever shape the caller - * passed"), must join that array into the same shape a real HTML text - * input would carry BEFORE it reaches the declared Text field, rather - * than rejecting it. This is exercised through the REAL - * getInputDescription()/grind() pipeline (createRealFieldsUiFactory(), - * not a mocked FormInput) via maybePerformAs() - the previously - * blocking regression this test guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUninstallsEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); @@ -679,24 +558,14 @@ private function createActivity( } /** - * Minimal stand-in for \ilObjLanguage: that class cannot be constructed or - * meaningfully mocked in a unit test (its constructor requires the global - * $DIC), so tests substitute this fake via UninstallLanguage's - * $obj_language_factory closure instead. Counts uninstall() invocations so - * tests can assert it was never called for a bucket other than - * "uninstalled". + * Stand-in for \ilObjLanguage (see above); counts uninstall() calls. */ final class FakeLanguageObject { private int $uninstall_calls = 0; /** - * @param string $uninstall_return_value What uninstall() returns - a - * non-empty string mimics a real, successful uninstall; '' - * mimics \ilObjLanguage::uninstall() internally rejecting the - * call despite the three outer guards (isSystemLanguage()/ - * isUserLanguage()/isInstalled()) having let it through - e.g. a - * future additional guard added inside uninstall() itself. + * @param string $uninstall_return_value '' mimics uninstall() rejecting the call despite the outer guards */ public function __construct( private readonly bool $is_system_language, diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index 1348e6dd3a94..d25bea413f8a 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -105,13 +105,7 @@ public function testMixedListPartitionsEachKeyIntoTheCorrectBucket(): void $this->assertSame(['fr'], $result['not_installed_language_keys']); } - /** - * getAvailableLanguagesForInstallation() (the $known_languages/$db_languages - * argument) and getLocalLanguages() ($local_language_keys) must be passed - * to registerInstalledLanguage() in exactly this order - both empty (or - * equal) values would make a swap of the two arguments invisible, so - * this test deliberately uses distinguishable shapes for each. - */ + /** registerInstalledLanguage() gets the available and the local languages in this order; distinguishable shapes expose a swap. */ public function testRegisterInstalledLanguageReceivesKnownLanguagesAndLocalLanguageKeysInTheCorrectOrder(): void { $setup_language = $this->createSetupLanguageMock( @@ -252,12 +246,7 @@ public function testInvalidLanguageKeysAreRejected(array $parameters): void $this->createActivity($this->createSetupLanguageMock([], [], []))->perform($parameters); } - /** - * Regression test for the perform()-parameter-type-check unification - * (see UpdateLanguage::perform()): a non-array $parameters must now - * raise the concrete InvalidInputException - not just the more general - * \InvalidArgumentException it extends. - */ + /** Pins InvalidInputException, not just its parent \InvalidArgumentException. */ public function testNonArrayParametersAreRejected(): void { $this->expectException(InvalidInputException::class); @@ -339,19 +328,7 @@ public function testPermissionGrantedPerformsAndReturnsOkResult(): void $this->assertSame(['de'], $result->value()['updated_language_keys']); } - /** - * Contract test: a real GUI caller (class.ilObjLanguageFolderGUI.php) - * always builds 'language_keys' as a PHP array of strings, never a - * comma-separated string - and GrindsFormInput::grind() (via its - * private joinListOfStringsRawValue() helper - see that method's own - * docblock) must join that array into the same shape a real HTML text - * input would carry BEFORE - * it reaches the declared Text field, rather than rejecting it. This - * is exercised through the REAL getInputDescription()/grind() pipeline - * (createRealFieldsUiFactory(), not a mocked FormInput) via - * maybePerformAs() - the previously blocking regression this test - * guards against. - */ + /** ilObjLanguageFolderGUI sends 'language_keys' as array of strings; the real grind() pipeline must join it. */ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne(): void { $rbac = $this->createMock(\ilRbacSystem::class); diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index ece0138b32f0..47718b2bef69 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -24,9 +24,6 @@ use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\Setup\Objective\NullObjective; -/** - * Class ilLanguageSetupAgentTest - */ class ilLanguageSetupAgentTest extends ilLanguageBaseTestCase { /** diff --git a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php index d67dd42878c5..5c8357943ba0 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageExtGUITest.php @@ -27,50 +27,22 @@ use Psr\Http\Message\ServerRequestInterface; /** - * ilObjLanguageExtGUI is bound tightly to the legacy $DIC/ilObjectGUI - * bootstrap, exactly like ilObjLanguageFolderGUI (see - * ilObjLanguageFolderGUITest's class docblock for the general technique this - * file reuses: ReflectionClass::newInstanceWithoutConstructor() plus - * reflection-injected collaborators). - * - * saveSettingsObject()/saveNewEntryObject() both unconditionally build a - * legacy ilPropertyFormGUI at some point (initNewSettingsForm()/ - * initAddNewEntryForm()) - and ilPropertyFormGUI's own constructor reaches - * into `global $DIC` for $DIC->language()/$DIC->ctrl() unconditionally. Since - * building that form is not what these tests are about (the actual - * behaviour under test is the Activity dispatch and the GUI's handling of - * its Result), initNewSettingsForm()/initAddNewEntryForm() are stubbed out - * via a partial mock of the GUI itself instead - the exact same technique - * ilObjLanguageFolderGUITest uses for checkPermission(). + * Built like ilObjLanguageFolderGUITest (newInstanceWithoutConstructor plus reflection-injected collaborators). + * The ilPropertyFormGUI builders are stubbed via a partial mock: the form constructor needs `global $DIC`. */ class ilObjLanguageExtGUITest extends TestCase { - /** - * $GLOBALS['DIC'] is genuinely global state shared by every test in this - * (single) PHP process - see ilObjLanguageFolderGUITest::tearDown() for - * the same reasoning/pattern. - */ + /** $GLOBALS['DIC'] is global state shared by all tests of the process. */ protected function tearDown(): void { unset($GLOBALS['DIC']); $this->stubbed_activity_error_logger = null; } - /** - * Holds whatever stubLoggerLangError() built for the currently running - * test, so the createGuiFor*() factories below can attach it to the - * GUI instance's `activity_error_logger` property (see - * RendersActivityErrors) instead of a plain permissive stub. Reset in - * tearDown() so it never leaks between tests. - */ + /** Set by stubLoggerLangError(), attached to the GUI's `activity_error_logger`; reset in tearDown(). */ private ?\ilLogger $stubbed_activity_error_logger = null; - /** - * The logger to wire onto a GUI instance's `activity_error_logger` - * property while building it for a test: whatever stubLoggerLangError() - * set up for this test, or - for tests that never expect a Throwable - * to be logged - a permissive stub. - */ + /** The logger of stubLoggerLangError(), or a permissive stub for tests expecting no logging. */ private function activityErrorLoggerForGui(): \ilLogger { return $this->stubbed_activity_error_logger ?? $this->createMock(\ilLogger::class); @@ -82,17 +54,7 @@ private function setProperty(object $object, string $property_name, mixed $value $property->setValue($object, $value); } - /** - * maybePerformAs() now takes the calling GUI's `ILIAS\UI\Factory::input()` - * as its (unused by these Activities, see LanguageActivity) first - * argument, so every reflection-/mock-builder-constructed GUI instance - * that reaches a maybePerformAs() call needs a `ui_factory` (a typed, - * non-nullable property on the ilObjectGUI ancestor) - otherwise PHP - * fatals with "must not be accessed before initialization" before the - * mocked Activity is ever reached. What input() actually returns is - * irrelevant to any of these tests (the Activities never use it), so a - * bare mock is enough. - */ + /** Every GUI reaching maybePerformAs() needs a `ui_factory` (typed property of ilObjectGUI); a bare mock suffices. */ private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&MockObject { $ui_factory = $this->createMock(\ILIAS\UI\Factory::class); @@ -103,15 +65,7 @@ private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&MockObject return $ui_factory; } - /** - * add_language_entry/set_language_translation_enabled are `private - * readonly` properties declared directly on ilObjLanguageExtGUI (not an - * ancestor) - on a PHPUnit mock subclass, plain `new - * ReflectionProperty($gui, name)` cannot locate a *readonly* property - * declared on the leaf class this way, so it must be looked up via the - * exact declaring class instead (see ilObjLanguageFolderGUITest's - * identical helper for 'current_user_id'). - */ + /** `private readonly` properties of the leaf class are not found on a PHPUnit mock subclass by a plain ReflectionProperty. */ private function setReadonlyPropertyDeclaredOnGuiClass( ilObjLanguageExtGUI $gui, string $property_name, @@ -132,15 +86,8 @@ private function createLanguageMockReturningTopicAsIs(array $installed_languages } /** - * activityErrorMessage() logs `get_class($error) . ': ' . - * $error->getMessage() . "\n" . $error->getTraceAsString()` via - * `$this->activity_error_logger` (see RendersActivityErrors) - the - * trace itself is environment-dependent (absolute file paths, line - * numbers), so only the ": \n" prefix is asserted - * here, not the full logged string. The built mock is stashed so the - * createGuiFor*() factories below can wire it onto the instance under - * test (see ilObjLanguageFolderGUITest::stubLoggerLangError() for the - * identical reasoning/pattern). + * Only the ": \n" prefix of the logged string is asserted, the trace is environment-dependent. + * The mock is wired onto the GUI by the createGuiFor*() factories. * * @param non-empty-string $expected_logged_message * @param class-string<\Throwable> $expected_exception_class @@ -181,10 +128,6 @@ private function createFakeLanguageObject(string $key): ilObjLanguageExt&MockObj return $object; } - // ----------------------------------------------------------------- - // saveSettingsObject() - // ----------------------------------------------------------------- - /** * @return ilObjLanguageExtGUI&MockObject */ @@ -225,12 +168,7 @@ private function createGuiForSaveSettings( return $gui; } - /** - * A non-empty, non-'' "translation" POST value means the checkbox was - * submitted (checked) - see saveSettingsObject()'s own docblock on the - * production class. This also pins down that the parameters are built - * from $this->object->key, not some other source. - */ + /** A non-empty "translation" POST value means the checkbox was checked; parameters come from $this->object->key. */ public function testSaveSettingsObjectBuildsEnabledTrueFromANonEmptyTranslationPostValue(): void { $user = $this->createMock(ilObjUser::class); @@ -252,11 +190,7 @@ public function testSaveSettingsObjectBuildsEnabledTrueFromANonEmptyTranslationP $gui->saveSettingsObject(); } - /** - * An unchecked HTML checkbox is not submitted at all - a missing - * "translation" POST key must build enabled=false, exactly like an - * explicit empty string does. - */ + /** An unchecked HTML checkbox is not submitted: a missing "translation" key builds enabled=false. */ public function testSaveSettingsObjectBuildsEnabledFalseWhenTranslationPostKeyIsMissing(): void { $user = $this->createMock(ilObjUser::class); @@ -300,11 +234,7 @@ public function testSaveSettingsObjectShowsSuccessMessageWhenResultReportsChange $gui->saveSettingsObject(); } - /** - * Only shown if the value actually changed - exactly reproducing the - * extracted code's original behaviour (see the 'changed' field's own - * description in SetLanguageTranslationEnabled::getOutputDescription()). - */ + /** Only shown if the value changed (see 'changed' in SetLanguageTranslationEnabled::getOutputDescription()). */ public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFalse(): void { $user = $this->createMock(ilObjUser::class); @@ -327,13 +257,7 @@ public function testSaveSettingsObjectShowsNoMessageWhenResultReportsChangedFals $gui->saveSettingsObject(); } - /** - * Unlike ilObjLanguageFolderGUI's write-command methods, saveSettingsObject() - * never redirects - it renders the settings form directly with a - * non-persisted message. A \Throwable error must still go through - * activityErrorMessage() (logged, generic message shown), never the raw - * message. - */ + /** saveSettingsObject() never redirects; a \Throwable still goes through activityErrorMessage(). */ public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirectsOnThrowableError(): void { $this->stubLoggerLangError('boom'); @@ -357,17 +281,10 @@ public function testSaveSettingsObjectShowsGenericFailureMessageAndNeverRedirect $this->createLanguageMockReturningTopicAsIs() ); - // saveSettingsObject() has no $ctrl collaborator injected at all - // here - if it ever tried to redirect, this would fatally error - // (Reflection-constructed instance, $ctrl never set), rather than - // silently passing. + // no $ctrl injected: a redirect would fatally error instead of silently passing $gui->saveSettingsObject(); } - // ----------------------------------------------------------------- - // saveNewEntryObject() - // ----------------------------------------------------------------- - /** * @return ilObjLanguageExtGUI&MockObject */ @@ -413,12 +330,7 @@ private function mockCheckedFormWithInputs(array $inputs): ilPropertyFormGUI&Moc return $form; } - /** - * Exercises the full parameter build-up: 'module'/'identifier' straight - * from the form, and 'translations' as an array keyed by every - * installed language (via $this->lng->getInstalledLanguages()), each - * value trimmed from the form's own "trans_" field. - */ + /** 'translations' is keyed by every installed language, values trimmed from the form's "trans_". */ public function testSaveNewEntryObjectBuildsModuleIdentifierAndTranslationsFromFormAcrossAllInstalledLanguages(): void { $user = $this->createMock(ilObjUser::class); @@ -484,12 +396,7 @@ public function testSaveNewEntryObjectShowsSuccessMessageAndRedirectsToViewOnSuc $gui->saveNewEntryObject(); } - /** - * A \Throwable error must show the generic, logged message (via - * activityErrorMessage()) and redirect to "view", exactly like - * ilObjLanguageFolderGUI's write commands - and must not crash/continue - * to the success path. - */ + /** A \Throwable shows the generic logged message and redirects to "view" without reaching the success path. */ public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError(): void { $this->stubLoggerLangError('boom'); @@ -520,23 +427,11 @@ public function testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToVi $this->createLanguageMockReturningTopicAsIs(['de']) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through past the error branch to the success path, - // which would call setOnScreenMessage('success', ...) a second - // time and $ctrl->redirect() a second time too - both `once()` - // expectations above would then fail rather than silently passing. + // a dropped `return` after the redirect would run the success path too and fail the once() expectations $gui->saveNewEntryObject(); } - /** - * Regression test: an input error (4xx other than 403, e.g. AddLanguageEntry's - * InvalidInputException for a missing/blank mandatory "de"/"en" value) - * must NOT redirect - unlike a generic internal failure (see - * testSaveNewEntryObjectShowsGenericFailureMessageAndRedirectsToViewOnThrowableError() - * above), the form is re-shown with the already-entered values still - * filled in ($form->setValuesByPost(), then addNewEntryObject($form)), - * exactly like the existing $form->checkInput() === false branch does. - */ + /** An input error (4xx other than 403) re-shows the form with the entered values instead of redirecting. */ public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPostedValuesOnAnInputError(): void { $user = $this->createMock(ilObjUser::class); @@ -553,18 +448,13 @@ public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPosted 'A value is required for: en.' ); - // No `true` (persisted) argument, and no redirect() at all - the - // form is re-rendered directly in this same request. + // no redirect: the form is re-rendered in this request $ctrl = $this->createMock(ilCtrl::class); $ctrl->expects($this->never())->method('redirect'); $form = $this->mockCheckedFormWithInputs(['mod' => 'common', 'id' => 'sometopic']); $form->expects($this->once())->method('setValuesByPost'); - // addNewEntryObject($form) (unmocked - it is the actual re-render - // path this test verifies is taken) calls `global $DIC["tpl"]` and - // renders $form->getHTML() into it - unrelated to this test's own - // concern (which is that saveNewEntryObject() takes this path and - // never redirects), so both are stubbed to harmless no-ops. + // addNewEntryObject($form) is the re-render path under test; its `global $DIC["tpl"]` access is stubbed $form->method('getHTML')->willReturn(''); $GLOBALS['DIC'] = new \ILIAS\DI\Container(); $GLOBALS['DIC']['tpl'] = $this->createMock(ilGlobalTemplateInterface::class); @@ -577,10 +467,7 @@ public function testSaveNewEntryObjectDoesNotRedirectAndReshowsTheFormWithPosted $form, $this->createLanguageMockReturningTopicAsIs(['de', 'en']) ); - // addNewEntryObject($form) also unconditionally checks - // $this->http->wrapper()->query()->has("eid") before falling - // through to the given $form - unrelated to this test's concern, - // stubbed to "no such query parameter". + // addNewEntryObject() also checks the "eid" query parameter: stubbed as absent $query_wrapper = $this->createMock(\ILIAS\HTTP\Wrapper\ArrayBasedRequestWrapper::class); $query_wrapper->method('has')->willReturn(false); $wrapper_factory = $this->createMock(\ILIAS\HTTP\Wrapper\WrapperFactory::class); diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 8fea10a03219..123d02fc7c47 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -28,35 +28,12 @@ use PHPUnit\Framework\TestCase; /** - * ilObjLanguageFolderGUI is bound tightly to the legacy $DIC/ilObjectGUI - * bootstrap (its constructor alone touches ilCtrl, ilTemplate, ilObjUser, - * ilRbac*, request wrappers, ...), so building a real instance is not - * practical for a unit test. installObject() itself is reachable in - * isolation, though: constructed via - * ReflectionClass::newInstanceWithoutConstructor() plus reflection-injected - * collaborators (a legitimate legacy-code testing technique - see - * docs/development or "Characterization Tests" practice), and called with - * an empty $ids array so the method's only other piece of legacy coupling - * (`new ilObjLanguage((int) $obj_id)` inside the per-id loop) is never - * reached. - * - * This covers the regression fix in installObject()'s error path: the - * previously swallowed $result->error() message must now appear in the - * failure message shown to the user. + * The GUI is bound to the legacy $DIC bootstrap, so it is built via newInstanceWithoutConstructor() plus + * reflection-injected collaborators and called with empty $ids, which avoids `new ilObjLanguage(...)` in the loop. */ class ilObjLanguageFolderGUITest extends TestCase { - /** - * Holds whatever stubLoggerLangError() built for the currently running - * test, so every createGuiWith*Collaborators()/createGuiWith* - * ()-style factory below can attach it to the GUI instance's - * `activity_error_logger` property (see RendersActivityErrors) instead - * of a plain permissive stub. Reset in tearDown() so it never leaks - * between tests - PHP runs every test in this class within the same - * process, and $GLOBALS['DIC'] (written by - * stubComponentRepositoryWithNoPlugins()) is genuinely global state too, - * for the same reason. - */ + /** Set by stubLoggerLangError(), attached to the GUI's `activity_error_logger`; reset in tearDown() as $GLOBALS['DIC'] is global state. */ private ?\ilLogger $stubbed_activity_error_logger = null; protected function tearDown(): void @@ -66,27 +43,11 @@ protected function tearDown(): void } /** - * activityErrorMessage() (see class docblock of the production class) - * logs a \Throwable's raw message via `$this->activity_error_logger` - * (a constructor-injected "lang" component logger - see - * RendersActivityErrors) before returning the generic, localized - * replacement text. This builds a mocked \ilLogger expecting exactly - * that call and stashes it so the GUI-building helpers below can wire - * it onto the instance under test via `activity_error_logger`. + * activityErrorMessage() logs the raw \Throwable via `activity_error_logger` before returning the generic text. + * Only the ": \n" prefix is asserted, the trace is environment-dependent. * - * activityErrorMessage() logs `get_class($error) . ': ' . - * $error->getMessage() . "\n" . $error->getTraceAsString()` (see - * RendersActivityErrors::activityErrorMessage()) - the trace itself is - * environment-dependent (absolute file paths, line numbers), so only - * the ": \n" prefix is asserted here, not the full - * logged string. - * - * @param non-empty-string $expected_logged_message The raw \Throwable - * message activityErrorMessage() must log - never the generic - * replacement text shown to the user. - * @param class-string<\Throwable> $expected_exception_class The class - * of the \Throwable the test's Result\Error is constructed - * with. + * @param non-empty-string $expected_logged_message the raw message, not the generic text shown to the user + * @param class-string<\Throwable> $expected_exception_class */ private function stubLoggerLangError( string $expected_logged_message, @@ -104,13 +65,7 @@ private function stubLoggerLangError( $this->stubbed_activity_error_logger = $logger; } - /** - * The logger to wire onto a GUI instance's `activity_error_logger` - * property while building it for a test: whatever stubLoggerLangError() - * set up for this test, or - for tests that never expect a Throwable - * to be logged - a permissive stub that tolerates being left untouched - * or called incidentally. - */ + /** The logger of stubLoggerLangError(), or a permissive stub for tests expecting no logging. */ private function activityErrorLoggerForGui(): \ilLogger { return $this->stubbed_activity_error_logger ?? $this->createMock(\ilLogger::class); @@ -136,17 +91,7 @@ private function createGuiWithCollaborators( return $gui; } - /** - * maybePerformAs() now takes the calling GUI's `ILIAS\UI\Factory::input()` - * as its (unused by these Activities, see LanguageActivity) first - * argument, so every reflection-constructed GUI instance that reaches a - * maybePerformAs() call needs a `ui_factory` (a typed, non-nullable - * property on the ilObjectGUI ancestor) - otherwise PHP fatals with - * "must not be accessed before initialization" before the mocked - * Activity is ever reached. What input() actually returns is irrelevant - * to any of these tests (the Activities never use it), so a bare mock - * is enough. - */ + /** Every GUI reaching maybePerformAs() needs a `ui_factory` (typed property of ilObjectGUI); a bare mock suffices. */ private function stubUiFactoryForMaybePerformAs(): \ILIAS\UI\Factory&\PHPUnit\Framework\MockObject\MockObject { $ui_factory = $this->createMock(\ILIAS\UI\Factory::class); @@ -171,54 +116,6 @@ private function createLanguageMockReturningTopicAsIs(): ilLanguage&\PHPUnit\Fra return $lng; } - // ----------------------------------------------------------------- - // Regression coverage for the explicit ACL check in installObject(), - // refreshSelectedObject(), uninstallObject() and - // uninstallChangesObject(). - // - // All four write-command methods on this class - installObject(), - // uninstallObject(), uninstallChangesObject() and - // refreshSelectedObject() - call $this->checkPermission('write') as - // their very first statement (verified against the class body; see - // also commit 4c98c957d36, "re-introduce explicit permission checks"). - // This is a defense-in-depth check: all four additionally delegate to - // their respective Activity's isAllowedToPerform() (via - // maybePerformAs()) - InstallLanguage, UninstallLanguage, UpdateLanguage - // and RemoveLocalLanguageChanges respectively - so the permission is - // enforced twice: once explicitly here, once inside the Activity (plus - // the `table_action` dispatch in executeCommand(), which calls - // checkPermission('write') itself before invoking any of them when - // reached that way - but any other caller of one of these *public* - // methods gets no ACL protection beyond the method's own explicit - // check and its Activity's isAllowedToPerform()). - // - // checkPermissionBool() (which checkPermission() delegates to) simply - // returns false whenever $this->object is not an object - which it - // never is on a reflection-constructed instance - so checkPermission() - // takes its early, silent `return;` branch and never actually reaches - // $this->access, $this->tpl or $this->ctrl. That makes it impossible - // to fake a "permission denied" *outcome* through $access in this kind - // of lightweight unit test: the method no-ops the same way regardless - // of whether real RBAC would grant or deny 'write', and - even if it - // didn't - checkPermission() itself never throws or exits, it only - // records a flash message and calls $ctrl->redirectToURL(), which is a - // real redirect+exit only at the HTTP layer; against a mocked $ctrl it - // is just another recorded call, so it would not actually stop - // execution here either. A real integration test (with a genuine - // ilAccessHandler backed by RBAC/DB and a request that never reaches - // executeCommand()'s own gate) would be needed to observe an *outcome* - // difference (data changed vs. not changed) for a denied user. - // - // What a unit test *can* pin down directly is the contract itself: - // that the method calls $this->checkPermission('write') exactly once, - // as its first statement, before doing any work. This is a legitimate - // case for interaction verification (rule 16): the presence of that - // call *is* the contract under test, not an implementation detail. A - // partial mock of the GUI itself (stubbing only checkPermission(), a - // protected method not otherwise observable, while every other method - // keeps its real implementation) makes that call directly assertable. - // ----------------------------------------------------------------- - /** @return ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject */ private function createGuiWithMockedCheckPermission(): ilObjLanguageFolderGUI { @@ -232,8 +129,7 @@ private function createGuiWithMockedCheckPermission(): ilObjLanguageFolderGUI $this->setProperty($gui, 'tpl', $this->createMock(ilGlobalTemplateInterface::class)); $this->setProperty($gui, 'ctrl', $this->createMock(ilCtrl::class)); $this->setProperty($gui, 'ui_factory', $this->stubUiFactoryForMaybePerformAs()); - // a readonly property declared on the leaf class cannot be found on a PHPUnit mock via a plain ReflectionProperty, - // so it is looked up on the declaring class + // a readonly property of the leaf class is not found on a mock via a plain ReflectionProperty $this->setReadonlyPropertyDeclaredOnGuiClass($gui, 'current_user_id', 6); $this->setReadonlyPropertyDeclaredOnGuiClass( $gui, @@ -254,18 +150,7 @@ private function setReadonlyPropertyDeclaredOnGuiClass( ->setValue($gui, $value); } - /** - * uninstallObject() must call $this->checkPermission('write') exactly - * once, as its first statement, before doing any uninstall work - - * exactly like installObject()/refreshSelectedObject()/ - * uninstallChangesObject() do. In addition, UninstallLanguage enforces - * the same 'write' permission itself via isAllowedToPerform()/ - * maybePerformAs() (see - * UninstallLanguageTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninstall() - * for that side of the contract) - this is a deliberate - * defense-in-depth: the GUI-level check here does not replace the - * Activity-level check, both must hold. - */ + /** checkPermission('write') is the first statement; the Activity enforces the same permission, both must hold. */ public function testUninstallObjectChecksWritePermissionBeforeActing(): void { $gui = $this->createGuiWithMockedCheckPermission(); @@ -280,13 +165,7 @@ public function testUninstallObjectChecksWritePermissionBeforeActing(): void $gui->uninstallObject([]); } - /** - * installObject() must check 'write' permission before doing any - * installation work - exactly like its siblings - * uninstallObject()/refreshSelectedObject()/uninstallChangesObject() - * do (see the class body: all four call $this->checkPermission('write') - * as their first statement). - */ + /** installObject() checks 'write' before any work, like its siblings. */ public function testInstallObjectMustCheckWritePermissionBeforeActing(): void { $gui = $this->createGuiWithMockedCheckPermission(); @@ -300,10 +179,7 @@ public function testInstallObjectMustCheckWritePermissionBeforeActing(): void $gui->installObject([], InstallLanguage::MODE_INSTALL); } - /** - * refreshSelectedObject() variant: same reasoning as - * testInstallObjectMustCheckWritePermissionBeforeActing() above. - */ + /** refreshSelectedObject() variant. */ public function testRefreshSelectedObjectMustCheckWritePermissionBeforeActing(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -318,18 +194,7 @@ public function testRefreshSelectedObjectMustCheckWritePermissionBeforeActing(): $gui->refreshSelectedObject([]); } - /** - * uninstallChangesObject() variant: same reasoning as - * testInstallObjectMustCheckWritePermissionBeforeActing() above. Also - * mirrors the defense-in-depth reasoning of - * testUninstallObjectChecksWritePermissionBeforeActing(): in addition - * to this explicit GUI-level check, RemoveLocalLanguageChanges enforces - * the same 'write' permission itself via isAllowedToPerform()/ - * maybePerformAs() (see - * RemoveLocalLanguageChangesTest::testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemoveLocalChanges() - * for that side of the contract) - the GUI-level check here does not - * replace the Activity-level check, both must hold. - */ + /** uninstallChangesObject() variant; the Activity enforces the same permission, both must hold. */ public function testUninstallChangesObjectMustCheckWritePermissionBeforeActing(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -350,26 +215,7 @@ public function testUninstallChangesObjectMustCheckWritePermissionBeforeActing() $gui->uninstallChangesObject([]); } - /** - * Regression test for installObject()'s explicit "empty selection" - * guard (checked BEFORE the Activity is ever called): an empty $ids - * must show the established 'no_checkbox' failure message and redirect - * to "view" WITHOUT ever calling InstallLanguage::maybePerformAs() - - * before this guard existed, an empty selection reached the Activity - * itself, which rejected it with a technical, untranslated message - * (e.g. "language_keys: not_min_length"). - * - * Note: because of this guard, $ids=[] can no longer be used (as it - * was before this production change) to exercise installObject()'s - * Result-handling logic (error/success/info message assembly, $mode - * pass-through) while sidestepping the method's other legacy coupling - * (`new ilObjLanguage((int) $obj_id)` for a non-empty $ids, which - * cannot be safely reached in this kind of lightweight unit test - see - * class docblock). That logic is therefore no longer directly - * unit-testable via this public method; the underlying business logic - * it merely assembles into a message remains covered by - * InstallLanguageTest (perform()/maybePerformAs()). - */ + /** An empty selection shows 'no_checkbox' and redirects without calling the Activity; the Result handling is covered by InstallLanguageTest. */ public function testInstallObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity(): void { $install_language = $this->createMock(InstallLanguage::class); @@ -449,22 +295,7 @@ private function updatePerformResult(array $updated, array $not_installed): arra ]; } - /** - * Regression test for refreshSelectedObject()'s explicit "empty - * selection" guard - same reasoning as - * testInstallObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity() - * above: an empty $ids must show 'no_checkbox' and redirect WITHOUT - * ever calling UpdateLanguage::maybePerformAs(). - * - * Note: as with installObject(), this guard means $ids=[] can no - * longer be used to exercise refreshSelectedObject()'s own - * Result-handling logic (error/success/info message assembly) while - * sidestepping its other legacy coupling (`new ilObjLanguage((int) - * $obj_id)` for a non-empty $ids) - see class docblock. That logic is - * no longer directly unit-testable via this public method; the - * underlying business logic it assembles into a message remains - * covered by UpdateLanguageTest (perform()/maybePerformAs()). - */ + /** An empty selection shows 'no_checkbox' and redirects without calling the Activity; the Result handling is covered by UpdateLanguageTest. */ public function testRefreshSelectedObjectWithEmptySelectionShowsNoCheckboxMessageAndNeverCallsTheActivity(): void { $update_language = $this->createMock(UpdateLanguage::class); @@ -491,16 +322,6 @@ public function testRefreshSelectedObjectWithEmptySelectionShowsNoCheckboxMessag $gui->refreshSelectedObject([]); } - // ----------------------------------------------------------------- - // uninstallObject() - // - // Same reflection-based construction technique as installObject()/ - // refreshSelectedObject() above, with $ids always [] for the same - // reason: the only other piece of legacy coupling in the method - // (`ilObject::_lookupTitle((int) $obj_id)` inside the per-id loop - // that builds $language_keys) must never be reached in a unit test. - // ----------------------------------------------------------------- - private function createGuiWithUninstallLanguageCollaborators( UninstallLanguage $uninstall_language, ilGlobalTemplateInterface $tpl, @@ -601,10 +422,7 @@ public function testUninstallObjectRedirectsToViewAfterAnErrorResultAndDoesNotCo new ResultError(new \RuntimeException('boom')) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to `$result->value()` - which throws on an - // Error result (see ILIAS\Data\Result\Error::value()) - so this - // would surface as a test error rather than silently passing. + // a dropped `return` after the redirect would reach `$result->value()`, which throws on an Error $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once())->method('setOnScreenMessage'); @@ -624,23 +442,7 @@ public function testUninstallObjectRedirectsToViewAfterAnErrorResultAndDoesNotCo $gui->uninstallObject([]); } - /** - * $ids is resolved to language keys via - * ilObject::_lookupTitle((int) $obj_id) - untestable in isolation - * without a real database, so - exactly like the existing - * install/update tests - this is only exercised with an empty $ids - * array, which is enough to pin that maybePerformAs() receives an - * (empty) 'language_keys' array and no other key. - * - * An empty $ids array means UninstallLanguage::toLanguageKeyList([]) - * would, in reality, throw \InvalidArgumentException('At least one - * language key is required.') - the real Activity is never reached - * here (it is mocked), but the mock is stubbed to return that same - * realistic error instead of an artificial Ok result, so this test does - * not silently pretend an empty request succeeds (m6). The 'with()' - * expectation below - the actual point of this test - is unaffected - * either way. - */ + /** $ids needs a real database to resolve, so only empty $ids is used; the mock returns the error the real Activity would. */ public function testUninstallObjectPassesOnlyLanguageKeysWithoutAModeKeyToMaybePerformAs(): void { $this->stubLoggerLangError('At least one language key is required.', \InvalidArgumentException::class); @@ -694,14 +496,7 @@ public function testUninstallObjectSetsSuccessMessageWhenOnlyUninstalledLanguage $gui->uninstallObject([]); } - /** - * system_language_keys, user_language_keys and not_installed_language_keys - * are all "nothing changed for this language, here is why" outcomes. - * setOnScreenMessage() only keeps one message per type, so - exactly - * like installObject()'s combined info message - all three buckets - * must be combined into a single 'info' call instead of three - * separate calls that would silently overwrite each other. - */ + /** setOnScreenMessage() keeps one message per type: the three "nothing changed" buckets must be combined into one 'info' call. */ public function testUninstallObjectCombinesSystemUserAndNotInstalledIntoOneInfoMessage(): void { $uninstall_language = $this->createMock(UninstallLanguage::class); @@ -733,12 +528,7 @@ public function testUninstallObjectCombinesSystemUserAndNotInstalledIntoOneInfoM $gui->uninstallObject([]); } - /** - * A single request can mix a genuinely uninstalled language with all - * three "nothing changed" buckets at once - the success message and - * the combined info message must both be set, as two separate calls - * (different message types), neither one clobbering the other. - */ + /** A mix of uninstalled and "nothing changed" languages sets the success and the combined info message, neither clobbering the other. */ public function testUninstallObjectSetsBothSuccessAndInfoMessagesWhenBothCategoriesAreNonEmpty(): void { $uninstall_language = $this->createMock(UninstallLanguage::class); @@ -771,10 +561,7 @@ public function testUninstallObjectSetsBothSuccessAndInfoMessagesWhenBothCategor self::assertSame(['info', 'cannot_uninstall_systemlanguage: meta_l_en'], $captured_calls[1]); } - /** - * Boundary: when every bucket is empty (e.g. an empty $ids request), - * no message at all must appear - not even an empty one. - */ + /** With every bucket empty no message appears, not even an empty one. */ public function testUninstallObjectSetsNoMessageWhenAllBucketsAreEmpty(): void { $uninstall_language = $this->createMock(UninstallLanguage::class); @@ -801,23 +588,6 @@ public function testUninstallObjectSetsNoMessageWhenAllBucketsAreEmpty(): void $gui->uninstallObject([]); } - // ----------------------------------------------------------------- - // uninstallChangesObject() - // - // Same reflection-based construction technique as uninstallObject() - // above, with $ids always [] for the same reason: the only other piece - // of legacy coupling in the method (`ilObject::_lookupTitle((int) - // $obj_id)` inside the per-id loop that builds $language_keys) must - // never be reached in a unit test. - // - // Unlike uninstallObject(), uninstallChangesObject() calls the legacy - // static ilObjLanguage::refreshPlugins() unconditionally on every - // success path (even when nothing was changed) - exactly like - // refreshSelectedObject() does (see stubComponentRepositoryWithNoPlugins()'s - // docblock above for why an empty plugin iterator is what keeps this a - // unit test), so every success-path test below needs that same stub. - // ----------------------------------------------------------------- - private function createGuiWithRemoveLocalLanguageChangesCollaborators( RemoveLocalLanguageChanges $remove_local_language_changes, ilGlobalTemplateInterface $tpl, @@ -855,10 +625,7 @@ private function removeLocalChangesPerformResult( public function testUninstallChangesObjectEmbedsTheThrowableMessageFromAnErrorResultIntoTheFailureMessage(): void { - // The error path is reached before ilObjLanguage::refreshPlugins() - // would ever be called, so stubComponentRepositoryWithNoPlugins() is - // not needed here - only the "lang" logger stub activityErrorMessage() - // now requires for a \Throwable error. + // the error path returns before ilObjLanguage::refreshPlugins(); only the "lang" logger stub is needed $exception_message = 'boom'; $this->stubLoggerLangError($exception_message); @@ -867,8 +634,7 @@ public function testUninstallChangesObjectEmbedsTheThrowableMessageFromAnErrorRe new ResultError(new \RuntimeException($exception_message)) ); - // uninstallChangesObject() wraps the message as - // "{error}
action_aborted", same as uninstallObject(). + // wrapped as "{error}
action_aborted", like uninstallObject() $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once()) ->method('setOnScreenMessage') @@ -914,11 +680,7 @@ public function testUninstallChangesObjectShowsTheNoPermissionMessageForA403Erro public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDoesNotContinueToTheSuccessPath(): void { - // The error path is reached (and returns) before - // ilObjLanguage::refreshPlugins() would ever be called, so - // stubComponentRepositoryWithNoPlugins() is not needed here - only - // the "lang" logger stub activityErrorMessage() now requires for a - // \Throwable error. + // the error path returns before ilObjLanguage::refreshPlugins(); only the "lang" logger stub is needed $this->stubLoggerLangError('boom'); $remove_local_language_changes = $this->createMock(RemoveLocalLanguageChanges::class); @@ -926,13 +688,7 @@ public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDo new ResultError(new \RuntimeException('boom')) ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to `$result->value()` - which throws on an - // Error result (see ILIAS\Data\Result\Error::value()) - and, even - // past that, to the unconditional ilObjLanguage::refreshPlugins() - // call, which reaches into `global $DIC["component.repository"]` - // and is not stubbed here - so this would surface as a test error - // rather than silently passing. + // a dropped `return` after the redirect would reach `$result->value()` and the unstubbed refreshPlugins() $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once())->method('setOnScreenMessage'); @@ -952,26 +708,7 @@ public function testUninstallChangesObjectRedirectsToViewAfterAnErrorResultAndDo $gui->uninstallChangesObject([]); } - /** - * $ids is resolved to language keys via - * ilObject::_lookupTitle((int) $obj_id) - untestable in isolation - * without a real database, so - exactly like the existing - * uninstallObject() test - this is only exercised with an empty $ids - * array, which is enough to pin that maybePerformAs() receives an - * (empty) 'language_keys' array and no other key. - * - * An empty $ids array means - * RemoveLocalLanguageChanges::toLanguageKeyList([]) would, in reality, - * throw \InvalidArgumentException('At least one language key is - * required.') - the real Activity is never reached here (it is - * mocked), but the mock is stubbed to return that same realistic error - * instead of an artificial Ok result, so this test does not silently - * pretend an empty request succeeds (m6). This also means the error - * path is taken, so stubComponentRepositoryWithNoPlugins() is no longer - * needed (refreshPlugins() is never reached on the error path) - only - * the "lang" logger stub activityErrorMessage() now requires for a - * \Throwable error is. - */ + /** $ids needs a real database to resolve, so only empty $ids is used; the mock returns the error the real Activity would. */ public function testUninstallChangesObjectPassesOnlyLanguageKeysWithoutAModeKeyToMaybePerformAs(): void { $this->stubLoggerLangError('At least one language key is required.', \InvalidArgumentException::class); @@ -1081,14 +818,7 @@ public function testUninstallChangesObjectSetsInfoMessageWhenOnlyNotInstalledLan $gui->uninstallChangesObject([]); } - /** - * Unlike uninstallObject()'s combined info message (three "nothing - * happened" buckets sharing one message type, therefore explicitly - * combined into one call to avoid a silent overwrite), all three - * buckets here are different message types (success/failure/info) - so - * all three setOnScreenMessage() calls must actually happen, as three - * separate calls, none clobbering another. - */ + /** The three buckets are different message types, so all three setOnScreenMessage() calls must happen. */ public function testUninstallChangesObjectSetsAllThreeMessagesWhenAllBucketsAreNonEmpty(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -1124,13 +854,7 @@ public function testUninstallChangesObjectSetsAllThreeMessagesWhenAllBucketsAreN self::assertSame(['info', 'meta_l_it language_not_installed'], $captured_calls[2]); } - /** - * Boundary: when every bucket is empty (e.g. an empty $ids request), - * no message at all must appear - not even an empty one. This is also - * a deliberate behavior change against the pre-Activity legacy code, - * which always showed a "selected_languages_updated" message, even - * when nothing changed. - */ + /** With every bucket empty no message appears; unlike the legacy code, which always showed "selected_languages_updated". */ public function testUninstallChangesObjectSetsNoMessageWhenAllBucketsAreEmpty(): void { $this->stubComponentRepositoryWithNoPlugins(); @@ -1159,32 +883,9 @@ public function testUninstallChangesObjectSetsNoMessageWhenAllBucketsAreEmpty(): $gui->uninstallChangesObject([]); } - // ----------------------------------------------------------------- - // enableLanguageDetectionObject() / disableLanguageDetectionObject() - // (both backed by setLanguageDetectionEnabledObject()) - // - // Unlike installObject()/uninstallObject()/refreshSelectedObject()/ - // uninstallChangesObject(), neither of these two methods calls - // $this->checkPermission('write') itself - this is unchanged legacy - // behaviour: before the extraction, only the toggle - // button's visibility in viewObject() was write-gated, the request - // itself was not. The extraction deliberately does not retrofit a - // GUI-level checkPermission() call here (that would be an unrequested, - // additional behavioural change on top of the intended one) - the - // *only* enforcement point is now SetLanguageDetectionEnabled's own - // isAllowedToPerform(), reached through maybePerformAs(). This is - // asserted directly below, in contrast to the defense-in-depth tests - // above for the other four write methods. - // - // Both action methods are `protected` (dispatched only via ilCtrl), - // hence invoked here through reflection. - // ----------------------------------------------------------------- - private function invokeProtectedMethod(object $object, string $method_name, array $args = []): mixed { - // ReflectionMethod::setAccessible() has had no effect (and has been - // deprecated) since PHP 8.1 - invoke() already bypasses visibility - // on its own since then. + // invoke() bypasses visibility, setAccessible() is deprecated since PHP 8.1 return (new ReflectionMethod($object, $method_name))->invoke($object, ...$args); } @@ -1210,11 +911,7 @@ private function createGuiWithSetLanguageDetectionEnabledCollaborators( public function testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledTrue(): void { - // The stubbed Error result's \Throwable is unconditionally passed - // through activityErrorMessage(), which now logs it via the "lang" - // component logger - this test is only about the maybePerformAs() - // arguments, but still needs the logger stub so the (irrelevant to - // this test) logging call does not fatally error on a null $DIC. + // the Error's \Throwable goes through activityErrorMessage(), which logs via the "lang" logger: needs the stub $this->stubLoggerLangError('boom'); $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); @@ -1235,9 +932,7 @@ public function testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledT public function testDisableLanguageDetectionObjectCallsMaybePerformAsWithEnabledFalse(): void { - // See testEnableLanguageDetectionObjectCallsMaybePerformAsWithEnabledTrue() - // above for why this stub is needed even though this test itself is - // only about the maybePerformAs() arguments. + // logger stub needed for the same reason as above $this->stubLoggerLangError('boom'); $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); @@ -1266,8 +961,7 @@ public function testSetLanguageDetectionEnabledObjectEmbedsTheThrowableMessageAn new ResultError(new \RuntimeException($exception_message)) ); - // setLanguageDetectionEnabledObject() wraps the message as - // "{error}
action_aborted", same as uninstallObject(). + // wrapped as "{error}
action_aborted", like uninstallObject() $tpl = $this->createMock(ilGlobalTemplateInterface::class); $tpl->expects($this->once()) ->method('setOnScreenMessage') @@ -1286,29 +980,11 @@ public function testSetLanguageDetectionEnabledObjectEmbedsTheThrowableMessageAn $this->createLanguageMockReturningTopicAsIs() ); - // If the `return` after the redirect were ever dropped, execution - // would fall through to the success path, which calls - // $this->viewObject() - a method that reaches deep into - // un-stubbed legacy collaborators ($this->languageFolderTable, - // $this->ui_factory, $this->toolbar, ...) on this - // reflection-constructed instance and would surface as a fatal - // error/TypeError rather than silently passing. + // a dropped `return` after the redirect would run the success path into un-stubbed legacy collaborators $this->invokeProtectedMethod($gui, 'enableLanguageDetectionObject'); } - /** - * Regression test documenting the deliberate asymmetry described above: - * unlike installObject()/uninstallObject()/refreshSelectedObject()/ - * uninstallChangesObject() (all four call $this->checkPermission('write') - * as their first statement, see the tests earlier in this class), - * enableLanguageDetectionObject()/disableLanguageDetectionObject() must - * NOT call checkPermission() themselves - this was true before the - * extraction and remains true after it. The only enforcement is now - * SetLanguageDetectionEnabled::isAllowedToPerform() via maybePerformAs() - * (see SetLanguageDetectionEnabledTest:: - * testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWritesTheSetting() - * for that side of the contract). - */ + /** Unlike the four write commands, enable/disableLanguageDetectionObject() do not call checkPermission(); only the Activity enforces it. */ public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission(): void { /** @var ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject $gui */ @@ -1330,11 +1006,7 @@ public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission() ); $set_language_detection_enabled = $this->createMock(SetLanguageDetectionEnabled::class); - // Permission denial is simulated at the Activity level (an Error - // result) so the success path (which would call $this->viewObject(), - // unreachable here) is never taken - this test is only about - // checkPermission() never being invoked, not about the outcome of - // the permission check itself. + // denial is simulated at Activity level so the success path (viewObject()) is not taken $set_language_detection_enabled->method('maybePerformAs')->willReturn( new ResultError(new \DomainException('no write permission', 403)) ); @@ -1348,43 +1020,12 @@ public function testSetLanguageDetectionEnabledObjectNeverCallsCheckPermission() $this->invokeProtectedMethod($gui, 'disableLanguageDetectionObject'); } - // ----------------------------------------------------------------- - // abortIfAnyIdIsNotALanguageObject() - the blocker fix: every request- - // supplied id in $ids must be confirmed to be a "lng" object BEFORE its - // title is ever trusted as a language key (see the method's own - // docblock in the production class for the full rationale). Exercised - // indirectly through every public/protected caller below, since the - // method itself is private. - // - // ilObject::_lookupType()/_lookupTitle() are both backed by - // `global $DIC["ilObjDataCache"]` (see class.ilObject.php) - stubbed via - // stubObjDataCache() below, exactly like stubComponentRepositoryWithNoPlugins() - // already stubs `global $DIC["component.repository"]` for - // ilObjLanguage::refreshPlugins(). - // ----------------------------------------------------------------- - /** - * Stubs `global $DIC["ilObjDataCache"]` (see ilObject::_lookupType()/ - * _lookupTitle()) so abortIfAnyIdIsNotALanguageObject() and the - * downstream `ilObject::_lookupTitle((int) $obj_id)` calls in the - * production class can be driven from a unit test without a real - * database. + * Stubs `global $DIC["ilObjDataCache"]` for ilObject::_lookupType()/_lookupTitle(). Reuses an existing + * $GLOBALS['DIC'] so it can coexist with stubComponentRepositoryWithNoPlugins(). * - * Reuses (rather than overwrites) an already-present $GLOBALS['DIC'] - * container - e.g. one stubComponentRepositoryWithNoPlugins() built - * first in the same test for ilObjLanguage::refreshPlugins() - so both - * stubs can coexist when a single test needs both. - * - * @param array $types_by_id obj_id => type (e.g. "lng", - * "cat"); an id missing from this array resolves to "" - a - * never-installed/non-existing object, exactly like the real - * ilObjectDataCache. - * @param array $titles_by_id obj_id => title. Ignored (and - * lookupTitle() must never even be called - enforced via - * expects($this->never())) when $expect_no_title_lookup is true, - * which every "the request must be aborted before any id's title - * is trusted" test below uses: that IS the property the blocker - * fix guarantees, not just an implementation detail. + * @param array $types_by_id obj_id => type; a missing id resolves to "" like the real ilObjectDataCache + * @param array $titles_by_id obj_id => title; lookupTitle() must never be called when $expect_no_title_lookup is true */ private function stubObjDataCache( array $types_by_id, @@ -1409,14 +1050,7 @@ private function stubObjDataCache( $GLOBALS['DIC']['ilObjDataCache'] = $cache; } - /** - * Regression test for installObject(): a single non-"lng" id among - * otherwise-plausible ids must abort the WHOLE request - the established - * 'obj_not_found'+'action_aborted' failure message, a redirect to - * "view", and InstallLanguage::maybePerformAs() must NEVER be called - * (i.e. nothing about the request is honoured, see - * abortIfAnyIdIsNotALanguageObject()'s own docblock). - */ + /** A single non-"lng" id aborts the whole request ('obj_not_found'+'action_aborted', redirect) without calling the Activity. */ public function testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1445,13 +1079,7 @@ public function testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCall $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); } - /** - * Regression test documenting that installObject()'s previous behaviour - * (for ids that ARE all "lng" objects) is unchanged by the blocker fix: - * InstallLanguage::maybePerformAs() must still be called, with - * 'language_keys' resolved from ilObject::_lookupTitle() exactly as - * before. - */ + /** For all-"lng" ids the Activity is still called, with 'language_keys' from ilObject::_lookupTitle(). */ public function testInstallObjectWithAllLanguageObjectIdsStillCallsInstallLanguageWithResolvedLanguageKeys(): void { $this->stubObjDataCache([5 => 'lng'], [5 => 'de']); @@ -1475,10 +1103,7 @@ public function testInstallObjectWithAllLanguageObjectIdsStillCallsInstallLangua $gui->installObject(['5'], InstallLanguage::MODE_INSTALL); } - /** - * uninstallObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** uninstallObject() variant. */ public function testUninstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUninstallLanguage(): void { $this->stubObjDataCache([5 => 'usr'], [], true); @@ -1507,11 +1132,7 @@ public function testUninstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCa $gui->uninstallObject(['5']); } - /** - * Regression test documenting that uninstallObject()'s previous - * behaviour (for ids that ARE all "lng" objects) is unchanged by the - * blocker fix. - */ + /** uninstallObject() with all-"lng" ids is unchanged. */ public function testUninstallObjectWithAllLanguageObjectIdsStillCallsUninstallLanguageWithResolvedLanguageKeys(): void { $this->stubObjDataCache([5 => 'lng'], [5 => 'de']); @@ -1535,10 +1156,7 @@ public function testUninstallObjectWithAllLanguageObjectIdsStillCallsUninstallLa $gui->uninstallObject(['5']); } - /** - * uninstallChangesObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** uninstallChangesObject() variant. */ public function testUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsRemoveLocalLanguageChanges(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1567,10 +1185,7 @@ public function testUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObjectAnd $gui->uninstallChangesObject(['5']); } - /** - * refreshSelectedObject() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(). - */ + /** refreshSelectedObject() variant. */ public function testRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsUpdateLanguage(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1600,23 +1215,14 @@ public function testRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObjectAndN } /** - * Builds a `ui_factory` stub sufficient for buildConfirmModal()'s abort - * branch: `$f->modal()->interruptive($title, '', '')->withActionButtonLabel(...)`. - * Returns the exact Interruptive instance that chain produces, so a test - * can assert buildConfirmModal() returned it unchanged (i.e. that the - * abort branch's `return` was actually taken, rather than falling - * through into the per-id loop). + * Stub for buildConfirmModal()'s abort branch; returns the Interruptive that chain produces, so a test can + * assert the abort branch's `return` was taken. * * @return array{0: \ILIAS\UI\Factory&\PHPUnit\Framework\MockObject\MockObject, 1: \ILIAS\UI\Implementation\Component\Modal\Interruptive} */ private function createStubModalUiFactory(): array { - // buildConfirmModal() is declared to return the concrete - // Implementation\Component\Modal\Interruptive class, not just the - // M\Interruptive interface - a mock of the interface alone fails - // that return type check, so the concrete class must be mocked - // here instead (createMock() disables its constructor, so the real - // SignalGeneratorInterface dependency is never actually needed). + // buildConfirmModal() returns the concrete Interruptive class, so the interface alone fails the return type $interruptive = $this->createMock(\ILIAS\UI\Implementation\Component\Modal\Interruptive::class); $interruptive->method('withActionButtonLabel')->willReturnSelf(); @@ -1629,21 +1235,7 @@ private function createStubModalUiFactory(): array return [$ui_factory, $interruptive]; } - /** - * buildConfirmModal() variant of - * testInstallObjectAbortsWhenAnyIdIsNotALanguageObjectAndNeverCallsInstallLanguage(): - * a non-"lng" id must abort exactly like the four write-command methods - * above (buildConfirmModal() delegates to the very same - * abortIfAnyIdIsNotALanguageObject()) - the method's return type still - * requires a Modal value even on abort (see the inline comment on that - * abort branch: never actually rendered in production, since the - * redirect takes precedence), which this test also pins down: the - * exact Interruptive instance the - * abort branch itself builds must be returned, proving the per-id loop - * (and therefore ilObject::_lookupTitle()/ilObjLanguage::_getLastLocalChange()) - * was never reached - no data belonging to the wrongly-typed object ever - * leaks into the built UI. - */ + /** buildConfirmModal() variant: the abort branch returns its own Interruptive, so the per-id loop was never reached. */ public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNeverLeaksData(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1680,12 +1272,7 @@ public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNever $this->assertSame($expected_interruptive, $result); } - /** - * confirmRefreshSelectedObject() variant of the abort tests above: takes - * $ids straight from its own $a_ids argument, so - unlike - * confirmUninstallObject()/confirmUninstallChangesObject() below - no - * request_wrapper/id_token stubbing is needed to reach the abort check. - */ + /** confirmRefreshSelectedObject() variant: takes $ids from its $a_ids argument, so no request_wrapper stubbing is needed. */ public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1711,14 +1298,7 @@ public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObj $gui->confirmRefreshSelectedObject(['5']); } - /** - * Builds a reflection-constructed GUI instance whose private - * getIdsFromQueryToken() (used by confirmUninstallObject()/ - * confirmUninstallChangesObject()) resolves to exactly $ids - stubbing - * `request_wrapper`/`id_token`/`refinery` (see the production method's - * body) rather than mocking the private method itself, which PHPUnit - * cannot intercept (private methods are not polymorphic in PHP). - */ + /** Stubs `request_wrapper`/`id_token`/`refinery` so the private getIdsFromQueryToken() resolves to $ids (PHPUnit cannot intercept private methods). */ private function createGuiWithIdsFromQueryToken( array $ids, ilLanguage $lng, @@ -1751,9 +1331,7 @@ private function createGuiWithIdsFromQueryToken( return $gui; } - /** - * confirmUninstallObject() variant of the abort tests above. - */ + /** confirmUninstallObject() variant. */ public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); @@ -1779,9 +1357,7 @@ public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): $gui->confirmUninstallObject(); } - /** - * confirmUninstallChangesObject() variant of the abort tests above. - */ + /** confirmUninstallChangesObject() variant. */ public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObject(): void { $this->stubObjDataCache([5 => 'cat'], [], true); From 39edbb60c5c9371fe8aff63ebe38f4fd0b06c38d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Mon, 19 Oct 2026 12:13:33 +0200 Subject: [PATCH 17/29] Language: Give the language service only to `AddLanguageEntry` `LanguageActivity` no longer takes `Language`: only `AddLanguageEntry` needs it, for the labels of its translation fields. The other six Activities, their `forSetup()` variants and the wiring drop the parameter. --- components/ILIAS/Language/Language.php | 6 ------ .../Language/src/Activities/AddLanguageEntry.php | 4 ++-- .../Language/src/Activities/InstallLanguage.php | 5 +---- .../Language/src/Activities/LanguageActivity.php | 5 ----- .../src/Activities/RemoveLocalLanguageChanges.php | 4 +--- .../src/Activities/SetLanguageDetectionEnabled.php | 4 +--- .../Activities/SetLanguageTranslationEnabled.php | 4 +--- .../Language/src/Activities/UninstallLanguage.php | 4 +--- .../Language/src/Activities/UpdateLanguage.php | 5 +---- .../tests/Activities/ActivityInputSectionTest.php | 12 ++++++------ .../tests/Activities/InstallLanguageTest.php | 9 +-------- .../Activities/LanguageActivitySetupVariantTest.php | 1 - .../tests/Activities/LanguageActivityTest.php | 13 ++----------- .../Activities/RemoveLocalLanguageChangesTest.php | 10 +--------- .../Activities/SetLanguageDetectionEnabledTest.php | 8 +------- .../SetLanguageTranslationEnabledTest.php | 8 +------- .../tests/Activities/UninstallLanguageTest.php | 9 +-------- .../tests/Activities/UpdateLanguageTest.php | 9 +-------- .../Language/tests/ilLanguageSetupAgentTest.php | 2 -- 19 files changed, 22 insertions(+), 100 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index cc467bdec4de..16b342e03093 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -82,7 +82,6 @@ public function init( $internal[InstallLanguage::class] = static fn() => new InstallLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $runtime_setup_language(), $internal[Language\LanguageFolder::class] @@ -90,7 +89,6 @@ public function init( $internal[UpdateLanguage::class] = static fn() => new UpdateLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $runtime_setup_language(), $internal[Language\LanguageFolder::class] @@ -105,7 +103,6 @@ public function init( $internal[UninstallLanguage::class] = static fn() => new UninstallLanguage( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -113,7 +110,6 @@ public function init( $internal[RemoveLocalLanguageChanges::class] = static fn() => new RemoveLocalLanguageChanges( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -129,7 +125,6 @@ public function init( $internal[SetLanguageDetectionEnabled::class] = static fn() => new SetLanguageDetectionEnabled( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], @@ -137,7 +132,6 @@ public function init( $internal[SetLanguageTranslationEnabled::class] = static fn() => new SetLanguageTranslationEnabled( - $use[\ILIAS\Language\Language::class], $use[\ILIAS\AccessControl\PublicInterface\RBAC::class], $internal[Language\LanguageFolder::class], $internal[RepositoryProvider::class], diff --git a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php index 74e602fd14f8..d578807fa0ec 100644 --- a/components/ILIAS/Language/src/Activities/AddLanguageEntry.php +++ b/components/ILIAS/Language/src/Activities/AddLanguageEntry.php @@ -31,12 +31,12 @@ class AddLanguageEntry extends LanguageActivity { public function __construct( - Language $language, + private readonly Language $lng, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/InstallLanguage.php b/components/ILIAS/Language/src/Activities/InstallLanguage.php index 90f14e92d044..f7ae7d7b44a9 100644 --- a/components/ILIAS/Language/src/Activities/InstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/InstallLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\Language\LanguageFolderLegacyProxy; use ILIAS\UI\Component\Input\Container\Form\FormInput; @@ -37,18 +36,16 @@ class InstallLanguage extends LanguageActivity public const string MODE_INSTALL_LOCAL = 'install_local'; public function __construct( - Language $language, ?RBAC $rbac, private readonly \ilSetupLanguage $setup_language, LanguageFolder $language_folder, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public static function forSetup(\ilSetupLanguage $setup_language): self { return new self( - $setup_language, null, $setup_language, // never read: without RBAC, isAllowedToPerform() throws first diff --git a/components/ILIAS/Language/src/Activities/LanguageActivity.php b/components/ILIAS/Language/src/Activities/LanguageActivity.php index 1b7db554bc08..64f5c0131f18 100644 --- a/components/ILIAS/Language/src/Activities/LanguageActivity.php +++ b/components/ILIAS/Language/src/Activities/LanguageActivity.php @@ -25,7 +25,6 @@ use ILIAS\Component\Activities\ActivityType; use ILIAS\Data\Result; use ILIAS\Data\Factory as DataFactory; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Factory as InputFactory; @@ -33,16 +32,12 @@ abstract class LanguageActivity extends ActivityImpl { use GrindsFormInput; - protected Language $lng; - /** @param RBAC|null $rbac null only for forSetup(); isAllowedToPerform() then throws. */ public function __construct( - Language $language, private readonly ?RBAC $rbac, private readonly LanguageFolder $language_folder, protected readonly DataFactory $data = new DataFactory(), ) { - $this->lng = $language; } public function getType(): ActivityType diff --git a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php index 6b763c6f967f..0bf7fa24a2fa 100644 --- a/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php +++ b/components/ILIAS/Language/src/Activities/RemoveLocalLanguageChanges.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; class RemoveLocalLanguageChanges extends LanguageActivity @@ -32,12 +31,11 @@ class RemoveLocalLanguageChanges extends LanguageActivity use ResolvesLanguageKeysToObjIds; public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php index 9708f3003c10..6e2cf0e96834 100644 --- a/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php +++ b/components/ILIAS/Language/src/Activities/SetLanguageDetectionEnabled.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -31,12 +30,11 @@ class SetLanguageDetectionEnabled extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php index eb7c42e6a49b..4ed0ffca208c 100644 --- a/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php +++ b/components/ILIAS/Language/src/Activities/SetLanguageTranslationEnabled.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -31,12 +30,11 @@ class SetLanguageTranslationEnabled extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/UninstallLanguage.php b/components/ILIAS/Language/src/Activities/UninstallLanguage.php index 12d7dc9e35b2..c6dfa6942020 100644 --- a/components/ILIAS/Language/src/Activities/UninstallLanguage.php +++ b/components/ILIAS/Language/src/Activities/UninstallLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; class UninstallLanguage extends LanguageActivity @@ -32,12 +31,11 @@ class UninstallLanguage extends LanguageActivity use ResolvesLanguageKeysToObjIds; public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly RepositoryProvider $repository, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/src/Activities/UpdateLanguage.php b/components/ILIAS/Language/src/Activities/UpdateLanguage.php index 3948fc02ada6..8a66b2fa0ae6 100644 --- a/components/ILIAS/Language/src/Activities/UpdateLanguage.php +++ b/components/ILIAS/Language/src/Activities/UpdateLanguage.php @@ -23,7 +23,6 @@ use ILIAS\AccessControl\PublicInterface\RBAC; use ILIAS\Data\Description; use ILIAS\Data\Text; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\Language\LanguageFolderLegacyProxy; @@ -32,18 +31,16 @@ class UpdateLanguage extends LanguageActivity use DeclaresLanguageKeysOnlyInput; public function __construct( - Language $language, ?RBAC $rbac, private readonly \ilSetupLanguage $setup_language, LanguageFolder $language_folder, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public static function forSetup(\ilSetupLanguage $setup_language): self { return new self( - $setup_language, null, $setup_language, // never read: without RBAC, isAllowedToPerform() throws first diff --git a/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php index 9995ecf9ff11..c6911e5a4623 100644 --- a/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php +++ b/components/ILIAS/Language/tests/Activities/ActivityInputSectionTest.php @@ -76,13 +76,13 @@ private function activity(string $name): LanguageActivity $repository = $this->createMock(RepositoryProvider::class); return match ($name) { - 'install' => new InstallLanguage($language, $rbac, $setup_language, $folder), - 'update' => new UpdateLanguage($language, $rbac, $setup_language, $folder), - 'uninstall' => new UninstallLanguage($language, $rbac, $folder, $repository), - 'remove' => new RemoveLocalLanguageChanges($language, $rbac, $folder, $repository), + 'install' => new InstallLanguage($rbac, $setup_language, $folder), + 'update' => new UpdateLanguage($rbac, $setup_language, $folder), + 'uninstall' => new UninstallLanguage($rbac, $folder, $repository), + 'remove' => new RemoveLocalLanguageChanges($rbac, $folder, $repository), 'add' => new AddLanguageEntry($language, $rbac, $folder, $repository), - 'detection' => new SetLanguageDetectionEnabled($language, $rbac, $folder, $repository), - 'translation' => new SetLanguageTranslationEnabled($language, $rbac, $folder, $repository), + 'detection' => new SetLanguageDetectionEnabled($rbac, $folder, $repository), + 'translation' => new SetLanguageTranslationEnabled($rbac, $folder, $repository), default => throw new \InvalidArgumentException($name), }; } diff --git a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php index f8368676d331..ec92890e4729 100644 --- a/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/InstallLanguageTest.php @@ -20,7 +20,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\UI\Component\Input\Field\Text; use ILIAS\UI\Component\Input\Field\Select; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -558,13 +557,9 @@ public function testPermissionDeniedBeforePerform(): void $setup_language = $this->createSetupLanguageMock([], [], []); $setup_language->expects($this->never())->method('getAvailableLanguagesForInstallation'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $setup_language, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de', 'mode' => InstallLanguage::MODE_INSTALL]); $this->assertTrue($result->isError()); @@ -573,10 +568,8 @@ public function testPermissionDeniedBeforePerform(): void private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): InstallLanguage { return new InstallLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $setup_language, $this->languageFolder() diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php index 9ebb1b68e28e..908818724509 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivitySetupVariantTest.php @@ -62,7 +62,6 @@ public function testWebActivityAsksRbacSystemOnlyInIsAllowedToPerform(): void $rbac->expects(self::once())->method('system')->willReturn($system); $activity = new InstallLanguage( - $this->createStub(\ILIAS\Language\Language::class), $rbac, $this->createStub(\ilSetupLanguage::class), $this->languageFolder(77) diff --git a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php index 18c9fe367a75..4b8e490ef382 100644 --- a/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php +++ b/components/ILIAS/Language/tests/Activities/LanguageActivityTest.php @@ -24,7 +24,6 @@ use ILIAS\Data\Description; use ILIAS\Data\Text; use ILIAS\Language\Activities\LanguageActivity; -use ILIAS\Language\Language; use ILIAS\Language\LanguageFolder; use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; @@ -49,7 +48,6 @@ public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500Resul $rbac->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityThrowingInputDescriptionTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac), $this->languageFolder() ); @@ -69,7 +67,6 @@ public function testExceptionWhileBuildingTheInputDescriptionIsMaskedAsA500Resul public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500ResultError(): void { $activity = new LanguageActivityThrowingInputDescriptionTestDouble( - $this->createMock(Language::class), $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), throw_type_error: true, @@ -87,7 +84,6 @@ public function testATypeErrorWhileBuildingTheInputDescriptionIsMaskedAsA500Resu public function testADefectThrownByGrindIsMaskedAsA500ResultError(): void { $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($this->createMock(\ilRbacSystem::class)), $this->languageFolder(), $this->createMock(FormInput::class), @@ -118,7 +114,6 @@ public function testIsAllowedToPerformRefusesNonUsersWithoutAskingRbac(?int $usr $rbac_system->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac_system), $this->languageFolder() ); @@ -132,7 +127,6 @@ public function testMaybePerformAsAnswers403ForANonUserAndNeverPerforms(): void $rbac_system->expects($this->never())->method('checkAccessOfUser'); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac_system), $this->languageFolder() ); @@ -152,7 +146,6 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm $rbac->method('checkAccessOfUser')->willReturn(true); $activity = new LanguageActivityPerformEchoTestDouble( - $this->createMock(Language::class), $this->rbacOf($rbac), $this->languageFolder() ); @@ -171,12 +164,11 @@ public function testMaybePerformAsPassesTheTrustedUsrIdToPerformOverASpoofedForm final class LanguageActivityThrowingInputDescriptionTestDouble extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly bool $throw_type_error = false, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown @@ -216,12 +208,11 @@ protected function normalizeParameters(array $grind_result): array final class LanguageActivityPerformEchoTestDouble extends LanguageActivity { public function __construct( - Language $language, RBAC $rbac, LanguageFolder $language_folder, private readonly ?FormInput $description = null, ) { - parent::__construct($language, $rbac, $language_folder); + parent::__construct($rbac, $language_folder); } public function getDescription(): Text\SimpleDocumentMarkdown diff --git a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php index f13d58bb738f..d73a56eb5b24 100644 --- a/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php +++ b/components/ILIAS/Language/tests/Activities/RemoveLocalLanguageChangesTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\AmbiguousLanguageTitleException; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\RemoveLocalLanguageChanges; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -240,7 +239,6 @@ public function testIsAllowedToPerformDelegatesToRbacSystemWithWriteAndTheConfig static fn(): array => [], static fn(int $id) => null, $rbac, - null, 42 ); @@ -442,14 +440,10 @@ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrRemove [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ 'de' => [], ]); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $lng_objects, $obj_language_factory, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -538,11 +532,9 @@ private function createActivity( \Closure $lng_objects, \Closure $obj_language_factory, ?\ilRbacSystem $rbac = null, - ?Language $language = null, int $language_folder_ref_id = 0 ): RemoveLocalLanguageChanges { return new RemoveLocalLanguageChanges( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder($language_folder_ref_id), $this->languageObjectProvider($lng_objects, $obj_language_factory) diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php index 8884ec3a7e5a..981e72fb27a8 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageDetectionEnabledTest.php @@ -18,7 +18,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\SetLanguageDetectionEnabled; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Checkbox; use ILIAS\Data\Description\Description; use ILIAS\Language\Activities\RepositoryProvider; @@ -171,10 +170,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverWrites $repository = $this->createMock(RepositoryProvider::class); $repository->expects($this->never())->method('setLanguageDetectionEnabled'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - - $result = $this->createActivity(rbac_system: $rbac, repository: $repository, language: $language) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['enabled' => true]); $this->assertTrue($result->isError()); @@ -342,11 +338,9 @@ public function testOutputDescriptionDescribesASingleBooleanEnabledField(): void private function createActivity( ?\ilRbacSystem $rbac_system = null, ?RepositoryProvider $repository = null, - ?Language $language = null, int $language_folder_ref_id = 0 ): SetLanguageDetectionEnabled { return new SetLanguageDetectionEnabled( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder($language_folder_ref_id), $repository ?? $this->createMock(RepositoryProvider::class) diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index 64e49d6e30a3..214e8ca19d26 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -18,7 +18,6 @@ use ILIAS\Language\Tests\Activities\ActivityContractTestCase; use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\SetLanguageTranslationEnabled; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Field\Text; use ILIAS\UI\Component\Input\Field\Checkbox; use ILIAS\Language\Activities\RepositoryProvider; @@ -204,10 +203,7 @@ public function testMaybePerformAsWithDeniedPermissionReturnsErrorAndNeverTouche $repository->expects($this->never())->method('isPageTranslationEnabled'); $repository->expects($this->never())->method('setPageTranslationEnabled'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - - $result = $this->createActivity(rbac_system: $rbac, repository: $repository, language: $language) + $result = $this->createActivity(rbac_system: $rbac, repository: $repository) ->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_key' => 'de', 'enabled' => true]); $this->assertTrue($result->isError()); @@ -528,7 +524,6 @@ public function testOutputDescriptionDescribesTheThreeDocumentedFieldsInOrder(): private function createActivity( ?\ilRbacSystem $rbac_system = null, ?RepositoryProvider $repository = null, - ?Language $language = null, int $language_folder_ref_id = 0, array $installed_languages = ['de', 'fr'], ): SetLanguageTranslationEnabled { @@ -536,7 +531,6 @@ private function createActivity( $repository->method('getInstalledLanguages')->willReturn($installed_languages); return new SetLanguageTranslationEnabled( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac_system ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder($language_folder_ref_id), $repository, diff --git a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php index 3f01aec42996..0b1a0c74b048 100644 --- a/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UninstallLanguageTest.php @@ -20,7 +20,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\UninstallLanguage; use ILIAS\UI\Component\Input\Field\Text; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; @@ -488,14 +487,10 @@ public function testPermissionDeniedBeforePerformNeverCallsObjectFactoryOrUninst [$fakes, $lng_objects, $obj_language_factory] = $this->buildFakeLanguageWorld([ 'de' => [], ]); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $lng_objects, $obj_language_factory, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -546,10 +541,8 @@ private function createActivity( \Closure $lng_objects, \Closure $obj_language_factory, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): UninstallLanguage { return new UninstallLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $this->languageFolder(), $this->languageObjectProvider($lng_objects, $obj_language_factory) diff --git a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php index d25bea413f8a..53f42f62fd8b 100644 --- a/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php +++ b/components/ILIAS/Language/tests/Activities/UpdateLanguageTest.php @@ -19,7 +19,6 @@ use ILIAS\Language\Activities\InvalidInputException; use ILIAS\Language\Activities\UpdateLanguage; use ILIAS\UI\Component\Input\Field\Text; -use ILIAS\Language\Language; use ILIAS\UI\Component\Input\Factory as InputFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -299,13 +298,9 @@ public function testPermissionDeniedBeforePerform(): void $setup_language = $this->createSetupLanguageMock([], [], ['de']); $setup_language->expects($this->never())->method('getInstalledLanguages'); $setup_language->expects($this->never())->method('flushLanguageForInstallation'); - $language = $this->createMock(Language::class); - $language->method('txt')->with('msg_no_perm_write')->willReturn('no write permission'); - $result = $this->createActivity( $setup_language, - $rbac, - $language + $rbac )->maybePerformAs($this->createRealFieldsUiFactory()->input(), 6, ['language_keys' => 'de']); $this->assertTrue($result->isError()); @@ -347,10 +342,8 @@ public function testMaybePerformAsAcceptsAnArrayOfLanguageKeysAndUpdatesEachOne( private function createActivity( ilSetupLanguage $setup_language, ?\ilRbacSystem $rbac = null, - ?Language $language = null ): UpdateLanguage { return new UpdateLanguage( - $language ?? $this->createMock(Language::class), $this->rbacOf($rbac ?? $this->createMock(\ilRbacSystem::class)), $setup_language, $this->languageFolder() diff --git a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php index 47718b2bef69..46e2055f28c5 100755 --- a/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php +++ b/components/ILIAS/Language/tests/ilLanguageSetupAgentTest.php @@ -36,13 +36,11 @@ protected function setUp(): void $refinery = new Refinery($this->createMock(DataFactory::class), $this->createMock(\ilLanguage::class)); $setup_language = $this->createMock(ilSetupLanguage::class); $install_language = new InstallLanguage( - $this->createMock(\ILIAS\Language\Language::class), $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), $setup_language, $this->languageFolder() ); $update_language = new UpdateLanguage( - $this->createMock(\ILIAS\Language\Language::class), $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), $setup_language, $this->languageFolder() From bcba0b52d33c0a290cbd618d4ca7e92d92291f3e Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:10:55 +0200 Subject: [PATCH 18/29] [FIX] File: take the current wording of could_not_create_file_objs --- components/ILIAS/File/lang/ilias_de.lang | 2 +- components/ILIAS/File/lang/ilias_en.lang | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ILIAS/File/lang/ilias_de.lang b/components/ILIAS/File/lang/ilias_de.lang index 573ab640fdd6..1027e6cec5fd 100644 --- a/components/ILIAS/File/lang/ilias_de.lang +++ b/components/ILIAS/File/lang/ilias_de.lang @@ -36,7 +36,7 @@ copyright_custom#:#Benutzerdefiniert copyright_custom_info#:#Lizenz auswählen, welche auf alle entpackten Dateien dieses Archivs angewandt wird. copyright_inherited#:#Vererbt copyright_inherited_info#:#Lizenz des Zip-Archivs auf seine entpackten Dateien anwenden.
Lizenz des Zip-Archivs: %s. -could_not_create_file_objs#:#Beim Erstellen der Dateiobjekte ist ein Fehler aufgetreten, wenden Sie sich an die Administrierenden der Plattform. +could_not_create_file_objs#:#Beim Erstellen der Dateiobjekte ist ein Fehler aufgetreten. Bitte wenden Sie sich an die technische Betreuung dieser Installation. de_activate_icon#:#(De-)Aktivieren download_ascii_filename#:#Nur ASCII-Zeichen im Download download_ascii_filename_info#:#Die Namen heruntergeladener Dateien enthalten nur ASCII-Zeichen und können so auf allen Systemen problemlos genutzt werden. Andere Zeichen werden durch Unterstriche ersetzt. Auf Installationen, die Dateinamen mit nicht-lateinischen Schriftzeichen nutzen, muss diese Option deaktiviert werden. diff --git a/components/ILIAS/File/lang/ilias_en.lang b/components/ILIAS/File/lang/ilias_en.lang index 79ad17b707bd..ef445b44660d 100644 --- a/components/ILIAS/File/lang/ilias_en.lang +++ b/components/ILIAS/File/lang/ilias_en.lang @@ -36,7 +36,7 @@ copyright_custom#:#Custom copyright_custom_info#:#Choose a custom copyright which will be applied to all unzipped files of this archive. copyright_inherited#:#Inherited copyright_inherited_info#:#Apply the copyright of the zip archive to its unzipped files.
Copyright of zip archive: %s. -could_not_create_file_objs#:#An error occurred while creating your file objects. Please contact the administrators of this platform. +could_not_create_file_objs#:#An error occurred while creating your file objects. Please contact this installation's technical support. de_activate_icon#:#Activate / Deactivate download_ascii_filename#:#Allow Only ASCII Characters in Downloaded Filenames download_ascii_filename_info#:#Downloaded files should only have ASCII-characters in their filename. Deactivate to use all characters. From b751724e852ae7ce86f8531cb91c320e9f5846d7 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:11:08 +0200 Subject: [PATCH 19/29] [REFACTOR] Language: add license header and restore @internal markers of the component translation classes --- .../CustomizingLanguageFileDirectory.php | 20 +++++++++++++++++-- .../LanguageFileDirectoryManager.php | 1 + .../MainLanguageFileDirectory.php | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php index 06ec277b73ee..e4fecb2ef39f 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php +++ b/components/ILIAS/Language/src/ComponentTranslation/CustomizingLanguageFileDirectory.php @@ -1,11 +1,27 @@ */ class LanguageFileDirectoryManager diff --git a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php index 592a67bfbae1..5fe11133d0ce 100644 --- a/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php +++ b/components/ILIAS/Language/src/ComponentTranslation/MainLanguageFileDirectory.php @@ -21,7 +21,8 @@ namespace ILIAS\Language\ComponentTranslation; /** - * @author Fabian Schmid + * @internal Only the Language component shall provide an instance of this. + * @author Fabian Schmid */ class MainLanguageFileDirectory implements LanguageFileDirectory { From 84eebae8e7bdd7ed2f2ff9c6d17496fb542070d2 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:12:51 +0200 Subject: [PATCH 20/29] [FIX] Language: invalidate the translation cache when lng_modules changes --- components/ILIAS/Language/Language.php | 6 +- .../classes/Setup/class.ilSetupLanguage.php | 7 +- .../Language/classes/class.ilObjLanguage.php | 5 +- .../src/Setup/LanguageInstallationManager.php | 3 + .../src/Setup/NullTranslationCache.php | 31 +++++++++ .../Language/src/Setup/TranslationCache.php | 29 +++++++++ .../src/Setup/TranslationCacheLegacyProxy.php | 33 ++++++++++ .../Setup/LanguageInstallationManagerTest.php | 65 +++++++++++++++++++ 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 components/ILIAS/Language/src/Setup/NullTranslationCache.php create mode 100644 components/ILIAS/Language/src/Setup/TranslationCache.php create mode 100644 components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 16b342e03093..61663ab4ef25 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -73,11 +73,15 @@ public function init( $internal[LanguageFileDirectoryManager::class] ); + $internal[Language\Setup\TranslationCache::class] = static fn() => + new Language\Setup\TranslationCacheLegacyProxy(); + $runtime_setup_language = static fn(): \ilSetupLanguage => new \ilSetupLanguage( "en", $internal[LanguageFileDirectoryManager::class], - $pull[\ILIAS\Database\Connection::class] + $pull[\ILIAS\Database\Connection::class], + $internal[Language\Setup\TranslationCache::class] ); $internal[InstallLanguage::class] = static fn() => diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index 16a8435b4ce5..a6b196f41610 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -25,6 +25,8 @@ use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCache; +use ILIAS\Language\Setup\NullTranslationCache; /** * language handling for setup @@ -62,6 +64,7 @@ public function __construct( string $a_lang_key, private ?LanguageFileDirectoryManager $language_file_directory_manager = null, private readonly ?ilDBInterface $connection = null, + private readonly TranslationCache $translation_cache = new NullTranslationCache(), ) { $this->lang_key = $a_lang_key ?: $this->lang_default; $this->absolute_path = (string) realpath(__DIR__ . "/../../../../../"); @@ -100,7 +103,9 @@ private function manager(): LanguageInstallationManager $this->db() ?? throw new LogicException("No database available for " . self::class . "."), $this->language_file_directory_manager, $this->absolute_path, - $this->repository() + $this->repository(), + null, + $this->translation_cache ); } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index 68a58bac602e..1286fcb7d1a4 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -24,6 +24,7 @@ use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCacheLegacyProxy; /** * Class ilObjLanguage @@ -94,7 +95,9 @@ public function __construct( $DIC->database(), $this->language_file_directory_manager, $this->absolute_path, - $this->repository + $this->repository, + null, + new TranslationCacheLegacyProxy() ); } diff --git a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php index 897051a7c2c4..e04fa1af6547 100644 --- a/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php +++ b/components/ILIAS/Language/src/Setup/LanguageInstallationManager.php @@ -45,6 +45,7 @@ public function __construct( private readonly string $absolute_path, private readonly InstalledLanguageRepository $repository, private readonly ?\Closure $now = null, + private readonly TranslationCache $translation_cache = new NullTranslationCache(), ) { } @@ -171,6 +172,7 @@ private function flushLanguage(string $lang_key, string $mode = "all"): void if ($mode === "all") { $this->db->manipulate("DELETE FROM lng_modules WHERE lang_key = " . $this->db->quote($lang_key, "text")); + $this->translation_cache->invalidate($lang_key); } } @@ -383,6 +385,7 @@ private function insertLanguage(string $lang_key, iterable $directories, array $ . implode(',', $modulesValuesSql) . ";"; $ilDB->manipulate($query); + $this->translation_cache->invalidate($lang_key); } finally { chdir($working_dir); } diff --git a/components/ILIAS/Language/src/Setup/NullTranslationCache.php b/components/ILIAS/Language/src/Setup/NullTranslationCache.php new file mode 100644 index 000000000000..6cffda31b90b --- /dev/null +++ b/components/ILIAS/Language/src/Setup/NullTranslationCache.php @@ -0,0 +1,31 @@ +deleteInCache(); + } +} diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php index f04aec52ba64..9d32ead0b7e2 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -24,6 +24,7 @@ use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\Setup\InstalledLanguageRepository; use ILIAS\Language\Setup\LanguageInstallationManager; +use ILIAS\Language\Setup\TranslationCache; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -842,6 +843,70 @@ public function isLocal(): bool }; } + public function testInsertLanguageInvalidatesTheTranslationCache(): void + { + $root = $this->createTempInstallationRoot(); + $this->writeLangFile($root . '/lang/ilias_de.lang', [['common', 'test', 'Global Value']]); + + try { + $db = $this->createDatabaseMock(); + $db->method('in')->willReturn("module IN ('common')"); + + $repository = $this->createMock(InstalledLanguageRepository::class); + $repository->method('getLocalChanges')->willReturn([]); + + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->once())->method('invalidate')->with('de'); + + $manager = new LanguageInstallationManager( + $db, + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + $root, + $repository, + null, + $cache + ); + + $manager->insertLanguageForInstallation('de'); + } finally { + $this->removeDirectory($root); + } + } + + public function testFlushLanguageForUninstallationInvalidatesTheTranslationCache(): void + { + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->once())->method('invalidate')->with('de'); + + $manager = new LanguageInstallationManager( + $this->createDatabaseMock(), + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + (string) realpath(__DIR__ . '/../../../../../'), + $this->createMock(InstalledLanguageRepository::class), + null, + $cache + ); + + $manager->flushLanguageForUninstallation('de'); + } + + public function testFlushLanguageForInstallationKeepsTheTranslationCache(): void + { + $cache = $this->createMock(TranslationCache::class); + $cache->expects($this->never())->method('invalidate'); + + $manager = new LanguageInstallationManager( + $this->createDatabaseMock(), + new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), + (string) realpath(__DIR__ . '/../../../../../'), + $this->createMock(InstalledLanguageRepository::class), + null, + $cache + ); + + $manager->flushLanguageForInstallation('de'); + } + private function createTempInstallationRoot(): string { $dir = sys_get_temp_dir() . '/ilias_lang_test_' . bin2hex(random_bytes(8)); From 53657985b9e9e14dc3d37939a447af57110b0dbf Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:13:50 +0200 Subject: [PATCH 21/29] [REFACTOR] Language: assert the contributions of the component independent of their order --- components/ILIAS/Language/Language.php | 4 +- components/ILIAS/Language/README.md | 2 +- .../tests/LanguageComponentGraphTest.php | 132 ++++++++++-------- 3 files changed, 79 insertions(+), 59 deletions(-) diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index 61663ab4ef25..aaeb67c91079 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -169,9 +169,7 @@ public function init( ); // Provided under the concrete class because Init.php pulls it, so none may become final - // (the GUI tests mock them). The $contribute order is the declaration order below and must - // stay between the Setup\Agent and the UserSettings entry: RenamingDIC numbers the repeated - // Activity key in call order (see LanguageComponentGraphTest). + // (the GUI tests mock them). $activities = [ InstallLanguage::class, UpdateLanguage::class, diff --git a/components/ILIAS/Language/README.md b/components/ILIAS/Language/README.md index 3fe34951ccd0..b3ddd271e779 100755 --- a/components/ILIAS/Language/README.md +++ b/components/ILIAS/Language/README.md @@ -124,4 +124,4 @@ All other HTML tags are unsupported and will be removed by `ilUtil::stripSlashes * [`tests/LanguageComponentGraphTest.php`](tests/LanguageComponentGraphTest.php) is a contract test for this component's `$define`/`$implement`/`$use`/`$contribute`/`$seek`/`$provide`/`$pull`/`$internal` wiring in `Language.php` and is a good place to check the current, authoritative wiring behaviour - (e.g. singleton guarantees, contribute ordering) rather than relying on prose descriptions here. + (e.g. singleton guarantees) rather than relying on prose descriptions here. diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index 84a884e66bc8..decc88db226d 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -51,6 +51,8 @@ */ class LanguageComponentGraphTest extends TestCase { + private PimpleContainer $contributions; + /** * @param array|\ArrayAccess|null $use * @return array{0: array, 1: RenamingDIC, 2: array, 3: RenamingDIC, 4: array, 5: PimpleContainer, 6: array, 7: PimpleContainer} @@ -75,7 +77,8 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ // RenamingDIC (as in the generated bootstrap) keeps several assignments to one key apart; a plain Pimple\Container overwrites $implement = new RenamingDIC(new PimpleContainer()); - $contribute = new RenamingDIC(new PimpleContainer()); + $this->contributions = new PimpleContainer(); + $contribute = new RenamingDIC($this->contributions); $provide = new PimpleContainer(); $internal = new PimpleContainer(); @@ -93,6 +96,32 @@ private function initComponent(array | \ArrayAccess | null $use = null, ?\ILIAS\ return [$define, $implement, $use, $contribute, $seek, $provide, $pull, $internal]; } + /** + * All instances contributed for $type, independent of the position of the contribution in init(). + * + * @return list + */ + private function contributionsOf(string $type): array + { + $contributions = []; + foreach ($this->contributions->keys() as $key) { + if (preg_match('/^' . preg_quote($type, '/') . '_\\d+$/', $key) === 1) { + $contributions[] = $this->contributions[$key]; + } + } + + return $contributions; + } + + private function contributedSetupAgent(): \ilLanguageSetupAgent + { + $agents = $this->contributionsOf(\ILIAS\Setup\Agent::class); + self::assertCount(1, $agents); + self::assertInstanceOf(\ilLanguageSetupAgent::class, $agents[0]); + + return $agents[0]; + } + public function testDefinesTheLanguageInterface(): void { [$define] = $this->initComponent(); @@ -137,57 +166,50 @@ public function testProvidedServicesShareTheSameInternalSingletonInstances(): vo ); } - public function testContributeEntriesResolveToExpectedConcreteClassesInPreservedOrder(): void + public function testContributeEntriesResolveToExpectedConcreteClasses(): void { - [, , , $contribute] = $this->initComponent(); - - // RenamingDIC suffixes every offsetSet with a container-wide "_" in call order, so these keys pin the order of - // the $contribute entries: a hardcoded counter shifts whenever a contribution is added ahead of it - self::assertInstanceOf(MainLanguageFileDirectory::class, $contribute[LanguageFileDirectory::class . '_0']); - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); - self::assertInstanceOf(InstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_2']); - self::assertInstanceOf(UpdateLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_3']); - self::assertInstanceOf(UninstallLanguage::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_4']); - self::assertInstanceOf(RemoveLocalLanguageChanges::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_5']); - self::assertInstanceOf(AddLanguageEntry::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_6']); - self::assertInstanceOf(SetLanguageDetectionEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_7']); - self::assertInstanceOf(SetLanguageTranslationEnabled::class, $contribute[\ILIAS\Component\Activities\Activity::class . '_8']); - self::assertInstanceOf(UserSettingsSettings::class, $contribute[\ILIAS\User\Settings\UserSettings::class . '_9']); + $this->initComponent(); + + $directories = $this->contributionsOf(LanguageFileDirectory::class); + self::assertCount(1, $directories); + self::assertInstanceOf(MainLanguageFileDirectory::class, $directories[0]); + + $this->contributedSetupAgent(); + + $activities = array_map( + static fn(object $activity): string => $activity::class, + $this->contributionsOf(\ILIAS\Component\Activities\Activity::class) + ); + sort($activities); + $expected = [ + InstallLanguage::class, + UpdateLanguage::class, + UninstallLanguage::class, + RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, + SetLanguageDetectionEnabled::class, + SetLanguageTranslationEnabled::class, + ]; + sort($expected); + self::assertSame($expected, $activities); + + $user_settings = $this->contributionsOf(\ILIAS\User\Settings\UserSettings::class); + self::assertCount(1, $user_settings); + self::assertInstanceOf(UserSettingsSettings::class, $user_settings[0]); } public function testContributedActivitiesAreTheSameSingletonsAsTheProvidedInstances(): void { - [, , , $contribute, , $provide] = $this->initComponent(); + [, , , , , $provide] = $this->initComponent(); // the Setup path and the GUI must share the very same Activity instances, not merely equal ones - self::assertSame( - $provide[InstallLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_2'] - ); - self::assertSame( - $provide[UpdateLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_3'] - ); - self::assertSame( - $provide[UninstallLanguage::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_4'] - ); - self::assertSame( - $provide[RemoveLocalLanguageChanges::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_5'] - ); - self::assertSame( - $provide[AddLanguageEntry::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_6'] - ); - self::assertSame( - $provide[SetLanguageDetectionEnabled::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_7'] - ); - self::assertSame( - $provide[SetLanguageTranslationEnabled::class], - $contribute[\ILIAS\Component\Activities\Activity::class . '_8'] - ); + $contributed = $this->contributionsOf(\ILIAS\Component\Activities\Activity::class); + foreach ([ + InstallLanguage::class, UpdateLanguage::class, UninstallLanguage::class, RemoveLocalLanguageChanges::class, + AddLanguageEntry::class, SetLanguageDetectionEnabled::class, SetLanguageTranslationEnabled::class, + ] as $class) { + self::assertTrue(in_array($provide[$class], $contributed, true), $class . ' is not contributed as provided'); + } } public function testImplementCandidatesAreOfferedInDeclarationOrder(): void @@ -221,7 +243,7 @@ public function testRepositoryGetsTheRootFromDirectories(): void self::assertSame('/ilias-root', self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'absolute_path')); } - private function resolveEverything(PimpleContainer $provide, RenamingDIC $contribute): void + private function resolveEverything(PimpleContainer $provide): void { foreach ([ InstallLanguage::class, UpdateLanguage::class, UninstallLanguage::class, RemoveLocalLanguageChanges::class, @@ -229,7 +251,7 @@ private function resolveEverything(PimpleContainer $provide, RenamingDIC $contri ] as $key) { self::assertIsObject($provide[$key]); } - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + $this->contributedSetupAgent(); } /** @@ -253,8 +275,8 @@ public function testDatabaseConsumersGetThePulledConnectionNotTheGlobals(): void $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 0); $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 2); - [, , , $contribute, , $provide, , $internal] = $this->initComponent(null, $connection); - $this->resolveEverything($provide, $contribute); + [, , , , , $provide, , $internal] = $this->initComponent(null, $connection); + $this->resolveEverything($provide); self::assertSame([], $internal[InstalledLanguageDatabaseRepository::class]->getInstalledLanguages()); // the module cache update of the provider shared by the runtime Activities; only its lookup runs without a legacy container @@ -267,12 +289,12 @@ public function testWiringNeverCallsRbacSystemWhileResolving(): void $rbac = $this->createMock(\ILIAS\AccessControl\PublicInterface\RBAC::class); $rbac->expects(self::never())->method('system'); - [, , , $contribute, , $provide] = $this->initComponent([ + [, , , , , $provide] = $this->initComponent([ \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), \ILIAS\AccessControl\PublicInterface\RBAC::class => $rbac, ]); - $this->resolveEverything($provide, $contribute); + $this->resolveEverything($provide); } public function testSetupAgentIsBuildableWithoutAnyRbacEntry(): void @@ -288,9 +310,9 @@ public function offsetGet(mixed $key): mixed }; $use[\ILIAS\Language\Language::class] = $this->createStub(\ILIAS\Language\Language::class); - [, , , $contribute] = $this->initComponent($use); + $this->initComponent($use); - self::assertInstanceOf(\ilLanguageSetupAgent::class, $contribute[\ILIAS\Setup\Agent::class . '_1']); + $this->contributedSetupAgent(); } /** @return array}> */ @@ -345,8 +367,8 @@ public function testSetupAgentActivitiesDoNotUseThePulledConnection(string $clas $GLOBALS['ilDB'] = $this->databaseExpectingQueries(\ilDBInterface::class, 1); $connection = $this->databaseExpectingQueries(\ILIAS\Database\Connection::class, 0); - [, , , $contribute] = $this->initComponent(null, $connection); - $agent = $contribute[\ILIAS\Setup\Agent::class . '_1']; + $this->initComponent(null, $connection); + $agent = $this->contributedSetupAgent(); $this->performReadOnly( self::readPrivateProperty($agent, $class === InstallLanguage::class ? 'install_language' : 'update_language') From 0645be228155ade841ef310adf61c9bb71fcf956 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:14:51 +0200 Subject: [PATCH 22/29] [REFACTOR] Language: hand the installation root to the repository instead of a path read during wiring --- components/ILIAS/Language/Language.php | 2 +- .../classes/Setup/class.ilSetupLanguage.php | 3 +- .../Language/classes/class.ilObjLanguage.php | 3 +- .../src/Setup/DirectoriesInstallationRoot.php | 39 +++++++++++++++++++ .../src/Setup/FixedInstallationRoot.php | 37 ++++++++++++++++++ .../Language/src/Setup/InstallationRoot.php | 29 ++++++++++++++ .../InstalledLanguageDatabaseRepository.php | 10 ++--- .../tests/LanguageComponentGraphTest.php | 19 ++++++++- ...nstalledLanguageDatabaseRepositoryTest.php | 5 ++- 9 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php create mode 100644 components/ILIAS/Language/src/Setup/FixedInstallationRoot.php create mode 100644 components/ILIAS/Language/src/Setup/InstallationRoot.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index aaeb67c91079..e1ac84e90d10 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -63,7 +63,7 @@ public function init( new InstalledLanguageDatabaseRepository( $pull[\ILIAS\Database\Connection::class], $internal[LanguageFileDirectoryManager::class], - $use[Directories::class]->getRoot() + new Language\Setup\DirectoriesInstallationRoot($use[Directories::class]) ); // Setup hands in its own database via setDbHandler() instead of the Connection diff --git a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php index a6b196f41610..b4da0e482969 100755 --- a/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php +++ b/components/ILIAS/Language/classes/Setup/class.ilSetupLanguage.php @@ -27,6 +27,7 @@ use ILIAS\Language\Setup\LanguageInstallationManager; use ILIAS\Language\Setup\TranslationCache; use ILIAS\Language\Setup\NullTranslationCache; +use ILIAS\Language\Setup\FixedInstallationRoot; /** * language handling for setup @@ -93,7 +94,7 @@ private function repository(): InstalledLanguageRepository return new InstalledLanguageDatabaseRepository( $this->db(), $this->language_file_directory_manager, - $this->absolute_path + new FixedInstallationRoot($this->absolute_path) ); } diff --git a/components/ILIAS/Language/classes/class.ilObjLanguage.php b/components/ILIAS/Language/classes/class.ilObjLanguage.php index 1286fcb7d1a4..c2aecee3dcc1 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguage.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguage.php @@ -25,6 +25,7 @@ use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; use ILIAS\Language\Setup\LanguageInstallationManager; use ILIAS\Language\Setup\TranslationCacheLegacyProxy; +use ILIAS\Language\Setup\FixedInstallationRoot; /** * Class ilObjLanguage @@ -89,7 +90,7 @@ public function __construct( $this->repository = new InstalledLanguageDatabaseRepository( $DIC->database(), $this->language_file_directory_manager, - $this->absolute_path + new FixedInstallationRoot($this->absolute_path) ); $this->manager = new LanguageInstallationManager( $DIC->database(), diff --git a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php new file mode 100644 index 000000000000..56e5bdf7e5ce --- /dev/null +++ b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php @@ -0,0 +1,39 @@ +directories->getRoot(); + } +} diff --git a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php new file mode 100644 index 000000000000..01f52ec55232 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php @@ -0,0 +1,37 @@ +path; + } +} diff --git a/components/ILIAS/Language/src/Setup/InstallationRoot.php b/components/ILIAS/Language/src/Setup/InstallationRoot.php new file mode 100644 index 000000000000..a3c686e99311 --- /dev/null +++ b/components/ILIAS/Language/src/Setup/InstallationRoot.php @@ -0,0 +1,29 @@ +language_file_directory_manager->getCustomizingDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (is_dir($path)) { $d = dir($path); chdir($path); @@ -176,7 +176,7 @@ public function getInstallableLanguages(): array // chdir() must be undone even on exceptions, see LanguageInstallationManager::insertLanguage() try { foreach ($this->language_file_directory_manager->getDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (is_dir($path)) { $d = dir($path); chdir($path); @@ -202,7 +202,7 @@ public function getInvalidLocalLanguageFiles(array $language_keys): array $language_keys = array_values(array_unique($language_keys)); foreach ($this->language_file_directory_manager->getCustomizingDirectories() as $directory) { - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); if (!is_dir($path)) { continue; } @@ -269,7 +269,7 @@ private function checkLanguageFile( ): bool { $working_dir = getcwd(); $lang_file = "ilias_" . $lang_key . ".lang" . $directory->getSuffix(); - $path = $this->absoluteDirectoryPath($this->absolute_path, $directory); + $path = $this->absoluteDirectoryPath($this->installation_root->getPath(), $directory); try { if (!is_dir($path)) { diff --git a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php index decc88db226d..f5341de7af94 100644 --- a/components/ILIAS/Language/tests/LanguageComponentGraphTest.php +++ b/components/ILIAS/Language/tests/LanguageComponentGraphTest.php @@ -240,7 +240,24 @@ public function testRepositoryGetsTheRootFromDirectories(): void \ILIAS\Environment\Configuration\Installation\Directories::class => $this->createDirectories('/ilias-root'), ]); - self::assertSame('/ilias-root', self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'absolute_path')); + self::assertSame( + '/ilias-root', + self::readPrivateProperty($internal[InstalledLanguageDatabaseRepository::class], 'installation_root')->getPath() + ); + } + + public function testRepositoryDoesNotReadTheRootWhileWiring(): void + { + $directories = $this->createMock(\ILIAS\Environment\Configuration\Installation\Directories::class); + $directories->expects(self::never())->method('getRoot'); + + [, , , , , , , $internal] = $this->initComponent([ + \ILIAS\Language\Language::class => $this->createStub(\ILIAS\Language\Language::class), + \ILIAS\AccessControl\PublicInterface\RBAC::class => $this->createStub(\ILIAS\AccessControl\PublicInterface\RBAC::class), + \ILIAS\Environment\Configuration\Installation\Directories::class => $directories, + ]); + + self::assertInstanceOf(InstalledLanguageDatabaseRepository::class, $internal[InstalledLanguageDatabaseRepository::class]); } private function resolveEverything(PimpleContainer $provide): void diff --git a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php index bb105df888f4..0685d89609b9 100644 --- a/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php +++ b/components/ILIAS/Language/tests/Setup/InstalledLanguageDatabaseRepositoryTest.php @@ -22,6 +22,7 @@ use ILIAS\Language\ComponentTranslation\LanguageFileDirectoryManager; use ILIAS\Language\ComponentTranslation\MainLanguageFileDirectory; use ILIAS\Language\Setup\InstalledLanguageDatabaseRepository; +use ILIAS\Language\Setup\FixedInstallationRoot; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -77,7 +78,7 @@ private function createRepository( return new InstalledLanguageDatabaseRepository( $db, new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), new MainLanguageFileDirectory()), - $absolute_path + new FixedInstallationRoot($absolute_path) ); } @@ -443,7 +444,7 @@ static function (): \Generator { $repository = new InstalledLanguageDatabaseRepository( $this->createReadDatabaseMock(), $manager, - '/does/not/matter/for/this/test' + new FixedInstallationRoot('/does/not/matter/for/this/test') ); $this->assertFalse($repository->checkLocalLanguageFile('de')); From 69eabd8a14299b109777def9c6382779516dfae5 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Mon, 19 Oct 2026 18:16:15 +0200 Subject: [PATCH 23/29] [REFACTOR] Language: take the settings of the Activities from a legacy proxy instead of a global $DIC fallback --- components/ILIAS/Language/Language.php | 4 + components/ILIAS/Language/ROADMAP.md | 4 +- .../src/Activities/LanguageSettings.php | 33 +++++++ .../LanguageSettingsLegacyProxy.php | 51 ++++++++++ .../src/Activities/RepositoryProvider.php | 23 +---- .../LanguageSettingsLegacyProxyTest.php | 92 +++++++++++++++++++ .../Activities/RepositoryProviderTest.php | 56 +++-------- 7 files changed, 200 insertions(+), 63 deletions(-) create mode 100644 components/ILIAS/Language/src/Activities/LanguageSettings.php create mode 100644 components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php create mode 100644 components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php diff --git a/components/ILIAS/Language/Language.php b/components/ILIAS/Language/Language.php index e1ac84e90d10..896c37f532d0 100644 --- a/components/ILIAS/Language/Language.php +++ b/components/ILIAS/Language/Language.php @@ -99,10 +99,14 @@ public function init( ); // runtime-only Activities route all legacy access through this provider + $internal[Language\Activities\LanguageSettings::class] = static fn() => + new Language\Activities\LanguageSettingsLegacyProxy(); + $internal[RepositoryProvider::class] = static fn() => new RepositoryProvider( $internal[InstalledLanguageDatabaseRepository::class], $pull[\ILIAS\Database\Connection::class], + $internal[Language\Activities\LanguageSettings::class], ); $internal[UninstallLanguage::class] = static fn() => diff --git a/components/ILIAS/Language/ROADMAP.md b/components/ILIAS/Language/ROADMAP.md index 82ed50cbe9b4..d707706e1c45 100755 --- a/components/ILIAS/Language/ROADMAP.md +++ b/components/ILIAS/Language/ROADMAP.md @@ -22,8 +22,8 @@ * GitHook for preventing duplicate use of same variable_ID in language files * Migrate the remaining `addCommandButton()` form buttons in `ilObjLanguageExtGUI` to KS Button * Take the system settings of `SetLanguageDetectionEnabled`/`SetLanguageTranslationEnabled` from the - dependency graph as soon as Administration offers them there; `Language.php` still reads - `$GLOBALS['DIC']->settings()` + dependency graph as soon as Administration offers them there; until then `LanguageSettingsLegacyProxy` + reads them from the legacy container * Let the GUI classes get the Activities by dependency injection once ilCtrl allows it, and drop the Activities and the `LanguageFileDirectoryManager` from the legacy `$DIC` (`AllModernComponents`) * Build `lng` in the dependency graph instead of `ilInitialisation::initLanguage()` diff --git a/components/ILIAS/Language/src/Activities/LanguageSettings.php b/components/ILIAS/Language/src/Activities/LanguageSettings.php new file mode 100644 index 000000000000..a43bb1b5543d --- /dev/null +++ b/components/ILIAS/Language/src/Activities/LanguageSettings.php @@ -0,0 +1,33 @@ +settings()->set('lang_detection', $enabled ? '1' : '0'); + } + + public function isPageTranslationEnabled(string $lang_key): bool + { + return (bool) $this->settings()->get('lang_translate_' . $lang_key, '0'); + } + + public function setPageTranslationEnabled(string $lang_key, bool $enabled): void + { + $this->settings()->set('lang_translate_' . $lang_key, $enabled ? '1' : '0'); + } + + private function settings(): Setting + { + global $DIC; + + return $DIC->settings(); + } +} diff --git a/components/ILIAS/Language/src/Activities/RepositoryProvider.php b/components/ILIAS/Language/src/Activities/RepositoryProvider.php index ff96c1ffb7fa..d664f6735936 100644 --- a/components/ILIAS/Language/src/Activities/RepositoryProvider.php +++ b/components/ILIAS/Language/src/Activities/RepositoryProvider.php @@ -20,19 +20,17 @@ namespace ILIAS\Language\Activities; -use ILIAS\Administration\Setting; use ILIAS\Language\Setup\InstalledLanguageRepository; /** - * Every legacy and repository access of the runtime language activities. No component offers the - * settings yet, so they are resolved from the legacy container on first use. + * Every legacy and repository access of the runtime language activities. */ class RepositoryProvider { public function __construct( private readonly InstalledLanguageRepository $installed_language_repository, private readonly \ilDBInterface $db, - private ?Setting $settings = null, + private readonly LanguageSettings $settings, ) { } @@ -120,32 +118,21 @@ public function lookupLogin(int $usr_id): string public function setLanguageDetectionEnabled(bool $enabled): void { - $this->settings()->set('lang_detection', $enabled ? '1' : '0'); + $this->settings->setLanguageDetectionEnabled($enabled); } public function isPageTranslationEnabled(string $lang_key): bool { - return (bool) $this->settings()->get('lang_translate_' . $lang_key, '0'); + return $this->settings->isPageTranslationEnabled($lang_key); } public function setPageTranslationEnabled(string $lang_key, bool $enabled): void { - $this->settings()->set('lang_translate_' . $lang_key, $enabled ? '1' : '0'); + $this->settings->setPageTranslationEnabled($lang_key, $enabled); } private function languageObject(int $obj_id): \ilObjLanguage { return new \ilObjLanguage($obj_id); } - - private function settings(): Setting - { - if (!$this->settings instanceof Setting) { - global $DIC; - - $this->settings = $DIC->settings(); - } - - return $this->settings; - } } diff --git a/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php b/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php new file mode 100644 index 000000000000..34166505313a --- /dev/null +++ b/components/ILIAS/Language/tests/Activities/LanguageSettingsLegacyProxyTest.php @@ -0,0 +1,92 @@ +dic_backup = $GLOBALS['DIC'] ?? null; + } + + protected function tearDown(): void + { + $GLOBALS['DIC'] = $this->dic_backup; + } + + private function withSettings(\ilSetting $settings): LanguageSettingsLegacyProxy + { + $dic = new \ILIAS\DI\Container(); + $dic['ilSetting'] = static fn(): \ilSetting => $settings; + $GLOBALS['DIC'] = $dic; + + return new LanguageSettingsLegacyProxy(); + } + + public function testDoesNotTouchTheLegacyContainerOnConstruction(): void + { + unset($GLOBALS['DIC']); + + $this->assertInstanceOf(LanguageSettingsLegacyProxy::class, new LanguageSettingsLegacyProxy()); + } + + /** @return array */ + public static function storedFlagProvider(): array + { + return ['"1"' => ['1', true], '"0"' => ['0', false], 'empty string' => ['', false]]; + } + + /** Falsy cast of the stored string, like ilObjLanguageAccess::_checkTranslate(), under the per-language key. */ + #[DataProvider('storedFlagProvider')] + public function testIsPageTranslationEnabledCastsTheStoredValueUnderThePerLanguageKey(string $stored, bool $expected): void + { + $settings = $this->createMock(\ilSetting::class); + $settings->expects($this->once())->method('get')->with('lang_translate_fr', '0')->willReturn($stored); + + $this->assertSame($expected, $this->withSettings($settings)->isPageTranslationEnabled('fr')); + } + + public function testSettingsAreWrittenAsOneAndZeroUnderTheirKeys(): void + { + $written = []; + $settings = $this->createMock(\ilSetting::class); + $settings->method('set')->willReturnCallback( + static function (string $key, string $value) use (&$written): void { + $written[] = [$key, $value]; + } + ); + + $proxy = $this->withSettings($settings); + $proxy->setLanguageDetectionEnabled(true); + $proxy->setLanguageDetectionEnabled(false); + $proxy->setPageTranslationEnabled('de', true); + $proxy->setPageTranslationEnabled('de', false); + + $this->assertSame( + [['lang_detection', '1'], ['lang_detection', '0'], ['lang_translate_de', '1'], ['lang_translate_de', '0']], + $written + ); + } +} diff --git a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php index b1135c806471..dc512e5cdad9 100644 --- a/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php +++ b/components/ILIAS/Language/tests/Activities/RepositoryProviderTest.php @@ -21,7 +21,7 @@ namespace ILIAS\Language\Tests\Activities; -use ILIAS\Administration\Setting; +use ILIAS\Language\Activities\LanguageSettings; use ILIAS\Language\Activities\RepositoryProvider; use ILIAS\Language\Setup\InstalledLanguageRepository; use PHPUnit\Framework\Attributes\DataProvider; @@ -39,7 +39,7 @@ public function testGetInstalledLanguagesDelegatesToTheRepository(): void $installed = $this->createMock(InstalledLanguageRepository::class); $installed->expects($this->once())->method('getInstalledLanguages')->willReturn(['de', 'en']); - $provider = new RepositoryProvider($installed, $this->createMock(\ilDBInterface::class)); + $provider = new RepositoryProvider($installed, $this->createMock(\ilDBInterface::class), $this->createMock(LanguageSettings::class)); $this->assertSame(['de', 'en'], $provider->getInstalledLanguages()); } @@ -75,7 +75,7 @@ public function testUpdateModuleCacheLeavesAMissingOrBrokenCacheAlone(?array $ro // unserialize() reports broken input as a notice, which is not what this test is about set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING); try { - (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db)) + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) ->updateModuleCache('de', 'common', 'topic', 'value'); } finally { restore_error_handler(); @@ -95,54 +95,24 @@ public function testUpdateModuleCacheQuotesLanguageKeyAndModuleIntoTheQuery(): v ); $db->method('fetchAssoc')->willReturn(null); - (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db)) + (new RepositoryProvider($this->createMock(InstalledLanguageRepository::class), $db, $this->createMock(LanguageSettings::class))) ->updateModuleCache('de', "co'mmon", 'topic', 'value'); } - /** @return array */ - public static function storedFlagProvider(): array + public function testSettingsAreDelegatedToTheLanguageSettings(): void { - return ['"1"' => ['1', true], '"0"' => ['0', false], 'empty string' => ['', false]]; - } - - /** Falsy cast of the stored string, like ilObjLanguageAccess::_checkTranslate(), under the per-language key. */ - #[DataProvider('storedFlagProvider')] - public function testIsPageTranslationEnabledCastsTheStoredValueUnderThePerLanguageKey(string $stored, bool $expected): void - { - $settings = $this->createMock(Setting::class); - $settings->expects($this->once())->method('get')->with('lang_translate_fr', '0')->willReturn($stored); - - $this->assertSame($expected, $this->providerWith($settings)->isPageTranslationEnabled('fr')); - } + $settings = $this->createMock(LanguageSettings::class); + $settings->expects($this->once())->method('setLanguageDetectionEnabled')->with(true); + $settings->expects($this->once())->method('isPageTranslationEnabled')->with('fr')->willReturn(true); + $settings->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); - public function testSettingsAreWrittenAsOneAndZeroUnderTheirKeys(): void - { - $written = []; - $settings = $this->createMock(Setting::class); - $settings->method('set')->willReturnCallback( - static function (string $key, string $value) use (&$written): void { - $written[] = [$key, $value]; - } - ); - - $provider = $this->providerWith($settings); - $provider->setLanguageDetectionEnabled(true); - $provider->setLanguageDetectionEnabled(false); - $provider->setPageTranslationEnabled('de', true); - $provider->setPageTranslationEnabled('de', false); - - $this->assertSame( - [['lang_detection', '1'], ['lang_detection', '0'], ['lang_translate_de', '1'], ['lang_translate_de', '0']], - $written - ); - } - - private function providerWith(Setting $settings): RepositoryProvider - { - return new RepositoryProvider( + $provider = new RepositoryProvider( $this->createMock(InstalledLanguageRepository::class), $this->createMock(\ilDBInterface::class), $settings ); + $provider->setLanguageDetectionEnabled(true); + $this->assertTrue($provider->isPageTranslationEnabled('fr')); + $provider->setPageTranslationEnabled('de', false); } } From cdc2f8ab71be454870d6b8b5d7f8e83efc290c45 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:26:55 +0200 Subject: [PATCH 24/29] Language: Protect the table actions of `ilObjLanguageFolderGUI` against CSRF The table actions were read from the query by `executeCommand()` without a command, so ilCtrl never checked a token. `ilObjLanguageFolderGUI` now implements `ilCtrlSecurityInterface`, runs the table actions only through the unsafe command `executeTableAction` and dispatches an exact allowlist of commands, since ilCtrl compares commands case-sensitively but PHP calls methods case-insensitively. Takes effect after the ctrl security artifact is rebuilt (`composer dump-autoload` or `setup build`). --- .../classes/class.ilObjLanguageFolderGUI.php | 263 ++++++++++-------- .../tests/ilObjLanguageFolderGUITest.php | 108 +++++++ 2 files changed, 252 insertions(+), 119 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index 38761cec1d6c..d35df6e6888c 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -37,10 +37,12 @@ * * @extends ilObject */ -class ilObjLanguageFolderGUI extends ilObjectGUI +class ilObjLanguageFolderGUI extends ilObjectGUI implements ilCtrlSecurityInterface { use RendersActivityErrors; + private const string CMD_TABLE_ACTION = 'executeTableAction'; + protected ilLanguageFolderTable $languageFolderTable; protected ILIAS\Data\Factory $df; protected URLBuilder $url_builder; @@ -74,8 +76,11 @@ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference $this->activity_error_logger = $DIC->logger()->lang(); $this->df = new ILIAS\Data\Factory(); - $here_uri = $this->df->uri($this->request->getUri()->__toString()); - $url_builder = new URLBuilder($here_uri); + // via ilCtrl, so that the rtoken of the unsafe table action command is appended + $url_builder = new URLBuilder($this->df->uri( + ilUtil::_getHttpPath() . '/' + . $this->ctrl->getLinkTargetByClass([ilAdministrationGUI::class, self::class], self::CMD_TABLE_ACTION) + )); $query_params_namespace = ['language_folder']; [$url_builder, $action_parameter_token, $row_id_token] = $url_builder->acquireParameters( @@ -93,6 +98,20 @@ public function __construct(?array $a_data, int $a_id, bool $a_call_by_reference $this->languageFolderTable = new ilLanguageFolderTable($folder, $url_builder, $action_parameter_token, $row_id_token); } + public function getUnsafeGetCommands(): array + { + return [ + self::CMD_TABLE_ACTION, + 'enableLanguageDetection', + 'disableLanguageDetection', + ]; + } + + public function getSafePostCommands(): array + { + return []; + } + /** * show installed languages */ @@ -711,127 +730,133 @@ public function executeCommand(): void default: $this->tabs_gui->activateTab("settings"); - if ($action = $this->getCommandFromQueryToken($this->action_token->getName())) { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); - $f = $this->ui_factory; - $r = $this->ui_renderer; - switch ($action) { - case 'confirmRefresh': - $ids = $this->confirmRefreshObject(); - $this->refreshSelectedObject($ids); - break; - case 'checkLanguage': - $this->checkLanguageObject(); - break; - case 'refresh': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'refreshConfirmed', - 'lang_refresh_confirm_selected', - "lang_refresh_confirm_info" - ); - echo($r->renderAsync([$modal])); - exit(); - case 'refreshConfirmed': - $ids = $this->getIdsFromQueryToken(); - $this->refreshSelectedObject($ids); - break; - case 'uninstall': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'uninstallConfirmed', - 'lang_uninstall_confirm' - ); - echo($r->renderAsync([$modal])); - exit(); - case 'uninstallConfirmed': - $ids = $this->getIdsFromQueryToken(); - $this->uninstallObject($ids); - break; - case 'install': - case 'install_local': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $ids[] = (string) $lang["obj_id"]; - } - } - // 'install'/'install_local' are the InstallLanguage mode values - $this->installObject($ids, $action); - break; - case 'lang_uninstall_changes': - $ids = $this->getIdsFromQueryToken(); - if (current($ids) === 'ALL_OBJECTS') { - array_shift($ids); - $languages = ilObject::_getObjectsByType("lng"); - foreach ($languages as $lang) { - $langObj = new ilObjLanguage((int) $lang["obj_id"], false); - if ($langObj->isInstalled()) { - $ids[] = (string) $lang["obj_id"]; - } - } - } - $modal = $this->buildConfirmModal( - $ids, - $action, - 'uninstallChanges', - 'lang_uninstall_changes_confirm' - ); - echo($r->renderAsync([$modal])); - exit(); - case 'uninstallChanges': - $ids = $this->getIdsFromQueryToken(); - $this->uninstallChangesObject($ids); - break; - case 'setSystemLanguage': - $ids = $this->getIdsFromQueryToken(); - $this->setSystemLanguageObject($ids); - break; - case 'setUserLanguage': - $ids = $this->getIdsFromQueryToken(); - $this->setUserLanguageObject($ids); - break; - case 'edit': - $ids = $this->getIdsFromQueryToken(); - $this->editFolderObject($ids); - break; + // exact match: ilCtrl compares the unsafe commands case-sensitively, PHP method calls are not + match ($cmd) { + self::CMD_TABLE_ACTION, + 'enableLanguageDetection', + 'disableLanguageDetection', + 'listDeprecated', + 'downloadDeprecated' => $this->{$cmd . 'Object'}(), + default => $this->viewObject(), + }; + + break; + } + } + + protected function executeTableActionObject(): void + { + $this->checkPermission("write"); + $this->lng->loadLanguageModule("meta"); + $r = $this->ui_renderer; + switch ($action = $this->getCommandFromQueryToken($this->action_token->getName())) { + case 'confirmRefresh': + $ids = $this->confirmRefreshObject(); + $this->refreshSelectedObject($ids); + break; + case 'checkLanguage': + $this->checkLanguageObject(); + break; + case 'refresh': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } } } - if (!$cmd) { - $cmd = "view"; + $modal = $this->buildConfirmModal( + $ids, + $action, + 'refreshConfirmed', + 'lang_refresh_confirm_selected', + "lang_refresh_confirm_info" + ); + echo($r->renderAsync([$modal])); + exit(); + case 'refreshConfirmed': + $ids = $this->getIdsFromQueryToken(); + $this->refreshSelectedObject($ids); + break; + case 'uninstall': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } + } } - $cmd .= "Object"; - $this->$cmd(); - + $modal = $this->buildConfirmModal( + $ids, + $action, + 'uninstallConfirmed', + 'lang_uninstall_confirm' + ); + echo($r->renderAsync([$modal])); + exit(); + case 'uninstallConfirmed': + $ids = $this->getIdsFromQueryToken(); + $this->uninstallObject($ids); + break; + case 'install': + case 'install_local': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $ids[] = (string) $lang["obj_id"]; + } + } + // 'install'/'install_local' are the InstallLanguage mode values + $this->installObject($ids, $action); + break; + case 'lang_uninstall_changes': + $ids = $this->getIdsFromQueryToken(); + if (current($ids) === 'ALL_OBJECTS') { + array_shift($ids); + $languages = ilObject::_getObjectsByType("lng"); + foreach ($languages as $lang) { + $langObj = new ilObjLanguage((int) $lang["obj_id"], false); + if ($langObj->isInstalled()) { + $ids[] = (string) $lang["obj_id"]; + } + } + } + $modal = $this->buildConfirmModal( + $ids, + $action, + 'uninstallChanges', + 'lang_uninstall_changes_confirm' + ); + echo($r->renderAsync([$modal])); + exit(); + case 'uninstallChanges': + $ids = $this->getIdsFromQueryToken(); + $this->uninstallChangesObject($ids); + break; + case 'setSystemLanguage': + $ids = $this->getIdsFromQueryToken(); + $this->setSystemLanguageObject($ids); + break; + case 'setUserLanguage': + $ids = $this->getIdsFromQueryToken(); + $this->setUserLanguageObject($ids); + break; + case 'edit': + $ids = $this->getIdsFromQueryToken(); + $this->editFolderObject($ids); break; } + $this->viewObject(); } protected function getCommandFromQueryToken(string $param): ?string diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 123d02fc7c47..240240b97570 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -1382,4 +1382,112 @@ public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageOb $gui->confirmUninstallChangesObject(); } + + public function testOnlyTheStateChangingCommandsAreUnsafeGetCommandsSoTheCsrfTokenIsRequired(): void + { + $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); + $this->assertInstanceOf(ilCtrlSecurityInterface::class, $gui); + + $this->assertEqualsCanonicalizing( + ['executeTableAction', 'enableLanguageDetection', 'disableLanguageDetection'], + $gui->getUnsafeGetCommands() + ); + $this->assertSame([], $gui->getSafePostCommands()); + } + + /** + * @param list $methods + * @return ilObjLanguageFolderGUI&\PHPUnit\Framework\MockObject\MockObject + */ + private function createDispatchGui(string $cmd, ?string $table_action, array $methods): ilObjLanguageFolderGUI + { + $gui = $this->getMockBuilder(ilObjLanguageFolderGUI::class) + ->disableOriginalConstructor() + ->onlyMethods(array_merge(['checkPermission', 'prepareOutput'], $methods)) + ->getMock(); + + $request_wrapper = $this->createStub(\ILIAS\HTTP\Wrapper\RequestWrapper::class); + $request_wrapper->method('has')->willReturn($table_action !== null); + $request_wrapper->method('retrieve')->willReturn($table_action); + $ctrl = $this->createStub(ilCtrl::class); + $ctrl->method('getNextClass')->willReturn(''); + $ctrl->method('getCmd')->willReturn($cmd); + $lng = $this->createStub(ilLanguage::class); + + foreach ([ + 'action_token' => new \ILIAS\UI\URLBuilderToken(['language_folder'], 'action'), + 'request_wrapper' => $request_wrapper, + 'refinery' => $this->createStub(\ILIAS\Refinery\Factory::class), + 'ctrl' => $ctrl, + 'lng' => $lng, + 'ui_renderer' => $this->createStub(\ILIAS\UI\Renderer::class), + 'tabs_gui' => $this->createStub(ilTabsGUI::class), + 'type' => 'lngf', + 'ref_id' => 1, + ] as $name => $value) { + (new ReflectionProperty(ilObjLanguageFolderGUI::class, $name))->setValue($gui, $value); + } + + return $gui; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('commandsThatMustFallBackToView')] + public function testExecuteCommandOnlyShowsTheViewForCommandsOutsideTheExactAllowlist(string $cmd): void + { + $gui = $this->createDispatchGui( + $cmd, + 'checkLanguage', + ['viewObject', 'executeTableActionObject', 'enableLanguageDetectionObject', 'saveObject', 'checkLanguageObject'] + ); + $gui->expects($this->once())->method('viewObject'); + $gui->expects($this->never())->method('executeTableActionObject'); + $gui->expects($this->never())->method('enableLanguageDetectionObject'); + $gui->expects($this->never())->method('saveObject'); + $gui->expects($this->never())->method('checkLanguageObject'); + + $gui->executeCommand(); + } + + /** @return array */ + public static function commandsThatMustFallBackToView(): array + { + return [ + 'upper case first letter' => ['ExecuteTableAction'], + 'lower case' => ['executetableaction'], + 'detection with upper case' => ['EnableLanguageDetection'], + 'inherited save' => ['save'], + 'empty' => [''], + ]; + } + + public function testExecuteCommandDispatchesTheExactTableActionCommandToTheTableAction(): void + { + $gui = $this->createDispatchGui('executeTableAction', null, ['viewObject', 'executeTableActionObject']); + $gui->expects($this->once())->method('executeTableActionObject'); + $gui->expects($this->never())->method('viewObject'); + + $gui->executeCommand(); + } + + public function testTableActionCheckLanguageIsDispatchedToCheckLanguageObject(): void + { + $gui = $this->createDispatchGui('executeTableAction', 'checkLanguage', ['viewObject', 'checkLanguageObject']); + $gui->expects($this->once())->method('checkLanguageObject'); + + $this->invokeTableAction($gui); + } + + public function testUnknownTableActionOnlyShowsTheView(): void + { + $gui = $this->createDispatchGui('executeTableAction', 'doesNotExist', ['viewObject', 'checkLanguageObject']); + $gui->expects($this->once())->method('viewObject'); + $gui->expects($this->never())->method('checkLanguageObject'); + + $this->invokeTableAction($gui); + } + + private function invokeTableAction(ilObjLanguageFolderGUI $gui): void + { + (new ReflectionMethod(ilObjLanguageFolderGUI::class, 'executeTableActionObject'))->invoke($gui); + } } From 7beef90df385119b05e907fa8c0c3472e22aead0 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:49:16 +0200 Subject: [PATCH 25/29] [FIX] Language: Load `ilLangDeprecated` through the autoloader in `ilObjLanguageFolderGUI` Listing and downloading the deprecated entries failed with an error, because `ilObjLanguageFolderGUI` still included `ilLangDeprecated` from the former `Services/Language` directory. --- .../ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index d35df6e6888c..a42e59f29794 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -605,9 +605,6 @@ public function setUserLanguageObject(array $ids): void $this->checkPermission("write"); $this->lng->loadLanguageModule("meta"); - #require_once "./Services/User/classes/class.ilObjUser.php"; - - if (count($ids) !== 1) { $this->tpl->setOnScreenMessage('failure', $this->lng->txt("choose_only_one_language") . "
" . $this->lng->txt("action_aborted"), true); $this->ctrl->redirect($this, "view"); @@ -1033,8 +1030,6 @@ public function listDeprecatedObject(): void ); $this->toolbar->addComponent($button); - include_once "./Services/Language/classes/class.ilLangDeprecated.php"; - $d = new ilLangDeprecated(); $res = ""; foreach ($d->getDeprecatedLangVars() as $key => $mod) { @@ -1049,7 +1044,6 @@ public function listDeprecatedObject(): void */ public function downloadDeprecatedObject(): void { - include_once "./Services/Language/classes/class.ilLangDeprecated.php"; $d = new ilLangDeprecated(); $res = ""; foreach ($d->getDeprecatedLangVars() as $key => $mod) { From 0599fd9053a0df6a6d3e5cc43dc442fbe4fb387d Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Wed, 21 Oct 2026 17:53:04 +0200 Subject: [PATCH 26/29] Language: Remove the unreachable confirmation screens from `ilObjLanguageFolderGUI` `confirmRefreshSelectedObject()`, `confirmUninstallObject()`, `confirmUninstallChangesObject()` and the table action `edit` could no longer be reached since the table actions confirm in modals, and their confirmations failed for lack of language ids. --- .../classes/class.ilObjLanguageFolderGUI.php | 102 ---------------- .../tests/ilObjLanguageFolderGUITest.php | 111 ------------------ 2 files changed, 213 deletions(-) diff --git a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php index a42e59f29794..31b831b9776a 100755 --- a/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php +++ b/components/ILIAS/Language/classes/class.ilObjLanguageFolderGUI.php @@ -848,10 +848,6 @@ protected function executeTableActionObject(): void $ids = $this->getIdsFromQueryToken(); $this->setUserLanguageObject($ids); break; - case 'edit': - $ids = $this->getIdsFromQueryToken(); - $this->editFolderObject($ids); - break; } $this->viewObject(); } @@ -884,104 +880,6 @@ public function confirmRefreshObject(): array return $ids; } - public function confirmRefreshSelectedObject(array $a_ids = []): void - { - $this->checkPermission("write"); - $this->lng->loadLanguageModule("meta"); - - $header = ''; - $ids = []; - if (!empty($a_ids)) { - $ids = $a_ids; - $header = $this->lng->txt("lang_refresh_confirm"); - } elseif (!empty($post_id = $this->getIdsFromQueryToken())) { - $ids = $post_id; - $header = $this->lng->txt("lang_refresh_confirm_selected"); - } else { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt("no_checkbox"), true); - $this->ctrl->redirect($this, "view"); - } - - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $conf_screen = new ilConfirmationGUI(); - $some_changed = false; - foreach ($ids as $id) { - $lang_key = ilObject::_lookupTitle((int) $id); - $lang_title = $this->lng->txt("meta_l_" . $lang_key); - $last_change = ilObjLanguage::_getLastLocalChange($lang_key); - if (!empty($last_change)) { - $some_changed = true; - $lang_title .= " (" . $this->lng->txt("last_change") . " " - . ilDatePresentation::formatDate(new ilDateTime($last_change, IL_CAL_DATETIME)) . ")"; - } - $conf_screen->addItem("id[]", (string) $id, $lang_title); - } - - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - if ($some_changed) { - $header .= "
" . $this->lng->txt("lang_refresh_confirm_info"); - } - $conf_screen->setHeaderText($header); - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "refreshSelected"); - $this->tpl->setContent($conf_screen->getHTML()); - } - - public function confirmUninstallObject(): void - { - $this->checkPermission("write"); - - $this->lng->loadLanguageModule("meta"); - - $ids = $this->getIdsFromQueryToken(); - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_confirm")); - foreach ($ids as $id) { - $lang_title = ilObject::_lookupTitle((int) $id); - $conf_screen->addItem("id[]", $id, $this->lng->txt("meta_l_" . $lang_title)); - } - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "uninstall"); - $this->tpl->setContent($conf_screen->getHTML()); - } - - public function confirmUninstallChangesObject(): void - { - $this->checkPermission('write'); - - $this->lng->loadLanguageModule("meta"); - - $ids = $this->getIdsFromQueryToken(); - if ($this->abortIfAnyIdIsNotALanguageObject($ids)) { - return; - } - - $conf_screen = new ilConfirmationGUI(); - $conf_screen->setFormAction($this->ctrl->getFormAction($this)); - $conf_screen->setHeaderText($this->lng->txt("lang_uninstall_changes_confirm")); - foreach ($ids as $id) { - $lang_title = ilObject::_lookupTitle($id); - $conf_screen->addItem("id[]", (string) $id, $this->lng->txt("meta_l_" . $lang_title)); - } - $conf_screen->setCancel($this->lng->txt("cancel"), "view"); - $conf_screen->setConfirm($this->lng->txt("ok"), "uninstallChanges"); - $this->tpl->setContent($conf_screen->getHTML()); - } - - protected function editFolderObject(array $ids): void - { - $this->ctrl->setParameterByClass("ilobjlanguageextgui", "obj_id", current($ids)); - $this->ctrl->redirectByClass("ilobjlanguageextgui"); - } - protected function disableLanguageDetectionObject(): void { $this->setLanguageDetectionEnabledObject(false); diff --git a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php index 240240b97570..0503f0f2419b 100644 --- a/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php +++ b/components/ILIAS/Language/tests/ilObjLanguageFolderGUITest.php @@ -1272,117 +1272,6 @@ public function testBuildConfirmModalAbortsWhenAnyIdIsNotALanguageObjectAndNever $this->assertSame($expected_interruptive, $result); } - /** confirmRefreshSelectedObject() variant: takes $ids from its $a_ids argument, so no request_wrapper stubbing is needed. */ - public function testConfirmRefreshSelectedObjectAbortsWhenAnyIdIsNotALanguageObject(): void - { - $this->stubObjDataCache([5 => 'cat'], [], true); - - /** @var ilObjLanguageFolderGUI $gui */ - $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); - - $tpl = $this->createMock(ilGlobalTemplateInterface::class); - $tpl->expects($this->once()) - ->method('setOnScreenMessage') - ->with('failure', 'obj_not_found
action_aborted', true); - - $ctrl = $this->createMock(ilCtrl::class); - $ctrl->expects($this->once())->method('redirect')->with( - $this->isInstanceOf(ilObjLanguageFolderGUI::class), - 'view' - ); - - $this->setProperty($gui, 'tpl', $tpl); - $this->setProperty($gui, 'ctrl', $ctrl); - $this->setProperty($gui, 'lng', $this->createLanguageMockReturningTopicAsIs()); - - $gui->confirmRefreshSelectedObject(['5']); - } - - /** Stubs `request_wrapper`/`id_token`/`refinery` so the private getIdsFromQueryToken() resolves to $ids (PHPUnit cannot intercept private methods). */ - private function createGuiWithIdsFromQueryToken( - array $ids, - ilLanguage $lng, - ilGlobalTemplateInterface $tpl, - ilCtrl $ctrl - ): ilObjLanguageFolderGUI { - /** @var ilObjLanguageFolderGUI $gui */ - $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); - - $id_token = new \ILIAS\UI\URLBuilderToken(['language_folder'], 'obj_ids'); - - $request_wrapper = $this->createMock(\ILIAS\HTTP\Wrapper\RequestWrapper::class); - $request_wrapper->method('has')->with($id_token->getName())->willReturn(true); - $request_wrapper->method('retrieve')->willReturn($ids); - - $custom_group = $this->createMock(\ILIAS\Refinery\Custom\Group::class); - $custom_group->method('transformation')->willReturn( - $this->createMock(\ILIAS\Refinery\Transformation::class) - ); - $refinery = $this->createMock(\ILIAS\Refinery\Factory::class); - $refinery->method('custom')->willReturn($custom_group); - - $this->setProperty($gui, 'id_token', $id_token); - $this->setProperty($gui, 'request_wrapper', $request_wrapper); - $this->setProperty($gui, 'refinery', $refinery); - $this->setProperty($gui, 'lng', $lng); - $this->setProperty($gui, 'tpl', $tpl); - $this->setProperty($gui, 'ctrl', $ctrl); - - return $gui; - } - - /** confirmUninstallObject() variant. */ - public function testConfirmUninstallObjectAbortsWhenAnyIdIsNotALanguageObject(): void - { - $this->stubObjDataCache([5 => 'cat'], [], true); - - $tpl = $this->createMock(ilGlobalTemplateInterface::class); - $tpl->expects($this->once()) - ->method('setOnScreenMessage') - ->with('failure', 'obj_not_found
action_aborted', true); - - $ctrl = $this->createMock(ilCtrl::class); - $ctrl->expects($this->once())->method('redirect')->with( - $this->isInstanceOf(ilObjLanguageFolderGUI::class), - 'view' - ); - - $gui = $this->createGuiWithIdsFromQueryToken( - ['5'], - $this->createLanguageMockReturningTopicAsIs(), - $tpl, - $ctrl - ); - - $gui->confirmUninstallObject(); - } - - /** confirmUninstallChangesObject() variant. */ - public function testConfirmUninstallChangesObjectAbortsWhenAnyIdIsNotALanguageObject(): void - { - $this->stubObjDataCache([5 => 'cat'], [], true); - - $tpl = $this->createMock(ilGlobalTemplateInterface::class); - $tpl->expects($this->once()) - ->method('setOnScreenMessage') - ->with('failure', 'obj_not_found
action_aborted', true); - - $ctrl = $this->createMock(ilCtrl::class); - $ctrl->expects($this->once())->method('redirect')->with( - $this->isInstanceOf(ilObjLanguageFolderGUI::class), - 'view' - ); - - $gui = $this->createGuiWithIdsFromQueryToken( - ['5'], - $this->createLanguageMockReturningTopicAsIs(), - $tpl, - $ctrl - ); - - $gui->confirmUninstallChangesObject(); - } - public function testOnlyTheStateChangingCommandsAreUnsafeGetCommandsSoTheCsrfTokenIsRequired(): void { $gui = (new ReflectionClass(ilObjLanguageFolderGUI::class))->newInstanceWithoutConstructor(); From bec2aa85d8f244804e94aead92a652fe0fffb474 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 09:07:13 +0200 Subject: [PATCH 27/29] Language: Pass the previous exception positionally in `GrindsFormInput` Named arguments are not used in the core (review criteria). --- components/ILIAS/Language/src/Activities/GrindsFormInput.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/Language/src/Activities/GrindsFormInput.php b/components/ILIAS/Language/src/Activities/GrindsFormInput.php index 3d749ce8dca9..b7cc00bfe868 100644 --- a/components/ILIAS/Language/src/Activities/GrindsFormInput.php +++ b/components/ILIAS/Language/src/Activities/GrindsFormInput.php @@ -60,7 +60,7 @@ private function grind(FormInput $description, array $raw_parameters): Result return new Result\Error($e); } catch (\InvalidArgumentException $e) { // a malformed request value, never an internal misconfiguration: safe to show the user - return new Result\Error(new InvalidInputException($e->getMessage(), previous: $e)); + return new Result\Error(new InvalidInputException($e->getMessage(), 400, $e)); } } From bdec72fb4f8b3bef036f5a3a85f28083601e7ff2 Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 09:07:24 +0200 Subject: [PATCH 28/29] Language: Expect the calls whose arguments the tests check PHPUnit 14 no longer allows `with()` without `expects()`, so the mocks in `SetLanguageTranslationEnabledTest`, `AddLanguageEntryTest` and `LanguageInstallationManagerTest` now expect their calls once. --- .../tests/Activities/AddLanguageEntryTest.php | 14 +++++++------- .../SetLanguageTranslationEnabledTest.php | 15 ++++++++------- .../Setup/LanguageInstallationManagerTest.php | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php index f19784e3a3e3..5f7613628f7c 100644 --- a/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php +++ b/components/ILIAS/Language/tests/Activities/AddLanguageEntryTest.php @@ -944,12 +944,12 @@ public function getInputDescription(FieldFactory $f): FormInput public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslationFields(): void { $module_text = $this->createMock(Text::class); - $module_text->method('withRequired')->with(true)->willReturnSelf(); - $module_text->method('withDedicatedName')->with('module')->willReturnSelf(); + $module_text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $module_text->expects($this->once())->method('withDedicatedName')->with('module')->willReturnSelf(); $identifier_text = $this->createMock(Text::class); - $identifier_text->method('withRequired')->with(true)->willReturnSelf(); - $identifier_text->method('withDedicatedName')->with('identifier')->willReturnSelf(); + $identifier_text->expects($this->once())->method('withRequired')->with(true)->willReturnSelf(); + $identifier_text->expects($this->once())->method('withDedicatedName')->with('identifier')->willReturnSelf(); $de_text = $this->createMock(Text::class); $de_required = null; @@ -957,7 +957,7 @@ public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslat $de_required = $required; return $de_text; }); - $de_text->method('withDedicatedName')->with('de')->willReturnSelf(); + $de_text->expects($this->once())->method('withDedicatedName')->with('de')->willReturnSelf(); $fr_text = $this->createMock(Text::class); $fr_required = null; @@ -965,7 +965,7 @@ public function testInputDescriptionBuildsModuleIdentifierAndPerLanguageTranslat $fr_required = $required; return $fr_text; }); - $fr_text->method('withDedicatedName')->with('fr')->willReturnSelf(); + $fr_text->expects($this->once())->method('withDedicatedName')->with('fr')->willReturnSelf(); $text_calls = []; $field = $this->createMock(\ILIAS\UI\Component\Input\Field\Factory::class); @@ -983,7 +983,7 @@ function (string $label, ?string $byline = null) use (&$text_calls, $module_text ); $translations_group = $this->createMock(\ILIAS\UI\Component\Input\Field\Group::class); - $translations_group->method('withDedicatedName')->with('translations')->willReturnSelf(); + $translations_group->expects($this->once())->method('withDedicatedName')->with('translations')->willReturnSelf(); $outer_section = $this->createMock(\ILIAS\UI\Component\Input\Field\Section::class); diff --git a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php index 214e8ca19d26..a02344ee0365 100644 --- a/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php +++ b/components/ILIAS/Language/tests/Activities/SetLanguageTranslationEnabledTest.php @@ -94,7 +94,8 @@ public function testPerformWritesOnlyWhenTheRequestedValueDiffersFromTheStoredOn ?bool $expected_written_value ): void { $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled') + $repository->expects($this->once()) + ->method('isPageTranslationEnabled') ->with('de') ->willReturn($stored_value); @@ -129,7 +130,7 @@ public function testPerformReadsTheCorrectPerLanguageSettingKeyWithDefaultZero() public function testPerformTrimsLanguageKeyForBothTheSettingKeyAndTheReturnedValue(): void { $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); $result = $this->createActivity(repository: $repository) @@ -177,7 +178,7 @@ public function testMaybePerformAsWithGrantedPermissionPerformsAndReturnsOkResul $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); $result = $this->createActivity(rbac_system: $rbac, repository: $repository) @@ -306,7 +307,7 @@ public function testMaybePerformAsWithMissingEnabledKeyDefaultsToFalseAndStillPe $rbac->expects($this->once())->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(true); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); $result = $this->createActivity(rbac_system: $rbac, repository: $repository) @@ -365,7 +366,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveTruthyEnabledRepresent $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); $result = $this->createActivity(rbac_system: $rbac, repository: $repository) @@ -386,7 +387,7 @@ public function testMaybePerformAsToleratesCommonPrimitiveFalsyEnabledRepresenta $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(true); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(true); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', false); $result = $this->createActivity(rbac_system: $rbac, repository: $repository) @@ -457,7 +458,7 @@ public function testMaybePerformAsTrimsLanguageKeyBeforeBuildingTheSettingKey(): $rbac->method('checkAccessOfUser')->willReturn(true); $repository = $this->createMock(RepositoryProvider::class); - $repository->method('isPageTranslationEnabled')->with('de')->willReturn(false); + $repository->expects($this->once())->method('isPageTranslationEnabled')->with('de')->willReturn(false); $repository->expects($this->once())->method('setPageTranslationEnabled')->with('de', true); $result = $this->createActivity(rbac_system: $rbac, repository: $repository) diff --git a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php index 9d32ead0b7e2..830e5342bdc5 100644 --- a/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php +++ b/components/ILIAS/Language/tests/Setup/LanguageInstallationManagerTest.php @@ -417,7 +417,7 @@ public function testInsertLanguageForApplyingLocalChangesPreservesUnrelatedSeedE }); $repository = $this->createMock(InstalledLanguageRepository::class); - $repository->method('getLanguageEntries')->with('de')->willReturn([ + $repository->expects($this->once())->method('getLanguageEntries')->with('de')->willReturn([ 'common' => [ 'overridden' => 'Old Value', 'untouched_by_customizing' => 'Still Here', @@ -467,7 +467,7 @@ public function testInstallLanguagesRegistersBrandNewLanguage(): void }); $repository = $this->createMock(InstalledLanguageRepository::class); - $repository->method('checkLanguage')->with('de')->willReturn(true); + $repository->expects($this->once())->method('checkLanguage')->with('de')->willReturn(true); $repository->method('getAvailableLanguages')->willReturn([]); $repository->method('getLocalLanguages')->willReturn([]); $repository->method('getLocalChanges')->willReturn([]); From cfeb84f2e947e7d1eed265d20e574aaa802b8b9f Mon Sep 17 00:00:00 2001 From: Christian Knof Date: Thu, 22 Oct 2026 11:29:34 +0200 Subject: [PATCH 29/29] Language: Mark the internal settings, cache and installation root types as `@internal` `TranslationCache`, `InstallationRoot`, `LanguageSettings` and their implementations are only wired in `$internal` and used inside the Language component, like the language file directories that already carry the marker. --- components/ILIAS/Language/src/Activities/LanguageSettings.php | 2 ++ .../Language/src/Activities/LanguageSettingsLegacyProxy.php | 2 ++ .../ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/FixedInstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/InstallationRoot.php | 2 ++ components/ILIAS/Language/src/Setup/NullTranslationCache.php | 2 ++ components/ILIAS/Language/src/Setup/TranslationCache.php | 2 ++ .../ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php | 2 ++ 8 files changed, 16 insertions(+) diff --git a/components/ILIAS/Language/src/Activities/LanguageSettings.php b/components/ILIAS/Language/src/Activities/LanguageSettings.php index a43bb1b5543d..255352d80c61 100644 --- a/components/ILIAS/Language/src/Activities/LanguageSettings.php +++ b/components/ILIAS/Language/src/Activities/LanguageSettings.php @@ -22,6 +22,8 @@ /** * The settings of the language administration changed by the Activities. + * + * @internal */ interface LanguageSettings { diff --git a/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php b/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php index 3c3c7817bf40..f9b52b802b03 100644 --- a/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php +++ b/components/ILIAS/Language/src/Activities/LanguageSettingsLegacyProxy.php @@ -24,6 +24,8 @@ /** * No component offers the settings yet, so they are taken from the legacy container on every call. + * + * @internal Only the Language component shall provide an instance of this. */ final class LanguageSettingsLegacyProxy implements LanguageSettings { diff --git a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php index 56e5bdf7e5ce..f2e78b3a73dc 100644 --- a/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/DirectoriesInstallationRoot.php @@ -24,6 +24,8 @@ /** * Reads the root from the Directories of the installation on every call, never at construction. + * + * @internal Only the Language component shall provide an instance of this. */ final readonly class DirectoriesInstallationRoot implements InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php index 01f52ec55232..dc425ac17f2d 100644 --- a/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/FixedInstallationRoot.php @@ -22,6 +22,8 @@ /** * A root determined by the caller, for the classes which are not wired by the component bootstrap. + * + * @internal Only the Language component shall provide an instance of this. */ final readonly class FixedInstallationRoot implements InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/InstallationRoot.php b/components/ILIAS/Language/src/Setup/InstallationRoot.php index a3c686e99311..9b7fdaa64244 100644 --- a/components/ILIAS/Language/src/Setup/InstallationRoot.php +++ b/components/ILIAS/Language/src/Setup/InstallationRoot.php @@ -22,6 +22,8 @@ /** * The root directory of the ILIAS installation, which the language file directories are relative to. + * + * @internal */ interface InstallationRoot { diff --git a/components/ILIAS/Language/src/Setup/NullTranslationCache.php b/components/ILIAS/Language/src/Setup/NullTranslationCache.php index 6cffda31b90b..e0bbc1b66e3d 100644 --- a/components/ILIAS/Language/src/Setup/NullTranslationCache.php +++ b/components/ILIAS/Language/src/Setup/NullTranslationCache.php @@ -22,6 +22,8 @@ /** * The Setup has no global cache, hence there is nothing to invalidate. + * + * @internal Only the Language component shall provide an instance of this. */ final class NullTranslationCache implements TranslationCache { diff --git a/components/ILIAS/Language/src/Setup/TranslationCache.php b/components/ILIAS/Language/src/Setup/TranslationCache.php index 2b02dd3ec431..88aadb61356e 100644 --- a/components/ILIAS/Language/src/Setup/TranslationCache.php +++ b/components/ILIAS/Language/src/Setup/TranslationCache.php @@ -22,6 +22,8 @@ /** * Cache of the translations of a language, which must be invalidated whenever lng_modules changes. + * + * @internal */ interface TranslationCache { diff --git a/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php b/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php index 092d341bc2cb..727501dc8147 100644 --- a/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php +++ b/components/ILIAS/Language/src/Setup/TranslationCacheLegacyProxy.php @@ -22,6 +22,8 @@ /** * Delegates to ilCachedLanguage at call time, so it can be built at build time without the global cache. + * + * @internal Only the Language component shall provide an instance of this. */ final class TranslationCacheLegacyProxy implements TranslationCache {