From 29756947e9132bf1cf2d51fc5b57481aafc6b0c3 Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Fri, 25 Oct 2024 11:05:53 +0100 Subject: [PATCH] =?UTF-8?q?Issue=20#3346707=20by=20tedbow,=20phenaproxima,?= =?UTF-8?q?=20alexpott,=20catch,=20wim=20leers,=20dww,=20effulgentsia,=20g?= =?UTF-8?q?=C3=A1bor=20hojtsy,=20drumm,=20grasmash,=20chrisfromredfin,=20f?= =?UTF-8?q?izcs3,=20cola,=20capysara,=20diegors,=20daisyleroy,=20abhishek?= =?UTF-8?q?=5Fgupta1,=20bnjmnm,=20quietone,=20lauriii,=20poker10,=20xjm,?= =?UTF-8?q?=20anish.a,=20ajits,=20traviscarden,=20heddn,=20Idoni,=20srisht?= =?UTF-8?q?iiee,=20siramsay,=20shabbir,=20rocketeerbkw,=20Schnitzel,=20s?= =?UTF-8?q?=5Fleu,=20Theresa.Grannum,=20yash.rode,=20wiifm,=20wendyZ,=20ti?= =?UTF-8?q?m.plunkett,=20Webbeh,=20rkoller,=20Ranjit1032002,=20kunal.sachd?= =?UTF-8?q?ev,=20kjankowski,=20jayesh.d,=20immaculatexavier,=20Ishani=20Pa?= =?UTF-8?q?tel,=20leksat,=20lhridley,=20percoction,=20rahul=5F,=20p.ayekum?= =?UTF-8?q?i,=20omkar.podey,=20narendra.rajwar27,=20narendrar:=20Add=20Alp?= =?UTF-8?q?ha=20level=20Experimental=20Package=20Manager=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.lock | 80 +- .../Metapackage/CoreRecommended/composer.json | 1 + core/.phpstan-baseline.php | 336 +++++++ core/composer.json | 3 +- core/misc/cspell/dictionary.txt | 2 + .../install/package_manager.settings.yml | 5 + .../config/schema/package_manager.schema.yml | 33 + .../package_manager/package_manager.api.php | 292 ++++++ .../package_manager/package_manager.info.yml | 8 + .../package_manager/package_manager.install | 71 ++ .../package_manager/package_manager.module | 107 +++ .../package_manager.services.yml | 107 +++ .../package_manager/src/ComposerInspector.php | 481 ++++++++++ .../src/Event/CollectPathsToExcludeEvent.php | 128 +++ .../src/Event/PostApplyEvent.php | 11 + .../src/Event/PostCreateEvent.php | 11 + .../src/Event/PostRequireEvent.php | 14 + .../src/Event/PreApplyEvent.php | 39 + .../src/Event/PreCreateEvent.php | 37 + .../src/Event/PreOperationStageEvent.php | 94 ++ .../src/Event/PreRequireEvent.php | 14 + .../src/Event/RequireEventTrait.php | 99 ++ .../package_manager/src/Event/StageEvent.php | 24 + .../src/Event/StatusCheckEvent.php | 74 ++ .../src/EventSubscriber/ChangeLogger.php | 184 ++++ .../EventSubscriber/UpdateDataSubscriber.php | 45 + .../src/Exception/ApplyFailedException.php | 22 + .../Exception/ComposerNotReadyException.php | 33 + .../src/Exception/StageEventException.php | 56 ++ .../src/Exception/StageException.php | 28 + .../Exception/StageFailureMarkerException.php | 22 + .../src/Exception/StageOwnershipException.php | 13 + .../package_manager/src/ExecutableFinder.php | 35 + .../package_manager/src/FailureMarker.php | 163 ++++ .../src/FileProcessOutputCallback.php | 76 ++ .../package_manager/src/ImmutablePathList.php | 35 + .../package_manager/src/InstalledPackage.php | 111 +++ .../src/InstalledPackagesList.php | 177 ++++ .../src/LegacyVersionUtility.php | 83 ++ .../package_manager/src/LoggingBeginner.php | 50 + .../package_manager/src/LoggingCommitter.php | 50 + .../package_manager/src/LoggingStager.php | 41 + .../src/PackageManagerServiceProvider.php | 87 ++ .../src/PackageManagerUninstallValidator.php | 65 ++ .../src/PackageManagerUpdateProcessor.php | 92 ++ .../src/PathExcluder/GitExcluder.php | 83 ++ .../src/PathExcluder/NodeModulesExcluder.php | 39 + .../SiteConfigurationExcluder.php | 145 +++ .../src/PathExcluder/SiteFilesExcluder.php | 66 ++ .../PathExcluder/SqliteDatabaseExcluder.php | 69 ++ .../src/PathExcluder/TestSiteExcluder.php | 41 + .../src/PathExcluder/UnknownPathExcluder.php | 201 +++++ .../PathExcluder/VendorHardeningExcluder.php | 46 + .../package_manager/src/PathLocator.php | 96 ++ .../src/Plugin/QueueWorker/Cleaner.php | 50 + .../package_manager/src/ProcessFactory.php | 97 ++ .../src/ProcessOutputCallback.php | 126 +++ .../package_manager/src/ProjectInfo.php | 230 +++++ .../modules/package_manager/src/StageBase.php | 851 ++++++++++++++++++ .../package_manager/src/StatusCheckTrait.php | 53 ++ .../src/TranslatableStringAdapter.php | 53 ++ .../src/TranslatableStringFactory.php | 56 ++ .../package_manager/src/ValidationResult.php | 150 +++ .../AllowedScaffoldPackagesValidator.php | 72 ++ .../BaseRequirementValidatorTrait.php | 50 + .../BaseRequirementsFulfilledValidator.php | 71 ++ .../ComposerMinimumStabilityValidator.php | 82 ++ .../Validator/ComposerPatchesValidator.php | 182 ++++ .../Validator/ComposerPluginsValidator.php | 227 +++++ .../src/Validator/ComposerValidator.php | 147 +++ .../src/Validator/DiskSpaceValidator.php | 147 +++ .../Validator/DuplicateInfoFileValidator.php | 114 +++ .../Validator/EnabledExtensionsValidator.php | 76 ++ .../Validator/EnvironmentSupportValidator.php | 75 ++ .../src/Validator/LockFileValidator.php | 185 ++++ .../src/Validator/MultisiteValidator.php | 63 ++ .../OverwriteExistingPackagesValidator.php | 80 ++ .../src/Validator/PendingUpdatesValidator.php | 74 ++ .../src/Validator/PhpExtensionsValidator.php | 108 +++ .../src/Validator/PhpTufValidator.php | 194 ++++ .../src/Validator/RsyncValidator.php | 77 ++ .../src/Validator/SettingsValidator.php | 49 + .../Validator/StageNotInActiveValidator.php | 54 ++ .../src/Validator/StagedDBUpdateValidator.php | 205 +++++ .../Validator/SupportedReleaseValidator.php | 138 +++ .../src/Validator/SymlinkValidator.php | 79 ++ .../Validator/WritableFileSystemValidator.php | 92 ++ .../fixtures/build_test_projects/README.md | 11 + .../alpha/1.0.0/alpha.info.yml.hide | 4 + .../alpha/1.0.0/composer.json | 5 + .../alpha/1.1.0/alpha.info.yml.hide | 4 + .../alpha/1.1.0/composer.json | 5 + .../main_module/composer.json | 5 + .../main_module/main_module.info.yml.hide | 4 + .../main_module_submodule.info.yml.hide | 4 + .../updated_module/1.0.0/composer.json | 5 + .../1.0.0/updated_module.info.yml.hide | 5 + .../1.0.0/updated_module.module | 18 + .../updated_module/1.1.0/composer.json | 5 + .../1.1.0/src/PostApplySubscriber.php | 54 ++ .../1.1.0/updated_module.info.yml.hide | 5 + .../1.1.0/updated_module.module | 18 + .../1.1.0/updated_module.services.yml | 7 + .../tests/fixtures/db_update.php | 14 + .../tests/fixtures/fake_site/.gitignore | 1 + .../tests/fixtures/fake_site/README.md | 19 + .../tests/fixtures/fake_site/_git/ignore.txt | 4 + .../tests/fixtures/fake_site/composer.json | 47 + .../tests/fixtures/fake_site/composer.lock | 88 ++ .../fake_site/custom/package/composer.json | 1 + .../fake_site/modules/example/_git/ignore.txt | 4 + .../modules/example/example.info.yml | 3 + .../tests/fixtures/fake_site/packages.json | 101 +++ .../fixtures/fake_site/private/exclude.txt | 1 + .../sites/default/default.services.yml | 2 + .../sites/default/default.settings.php | 6 + .../fake_site/sites/default/services.yml | 2 + .../sites/default/settings.local.php | 6 + .../fake_site/sites/default/settings.php | 6 + .../fake_site/sites/default/stage.txt | 1 + .../fake_site/sites/example.com/db.sqlite | 1 + .../fake_site/sites/example.com/db.sqlite-shm | 1 + .../fake_site/sites/example.com/db.sqlite-wal | 1 + .../sites/example.com/files/exclude.txt | 1 + .../fake_site/sites/example.com/services.yml | 2 + .../sites/example.com/settings.local.php | 6 + .../fake_site/sites/example.com/settings.php | 6 + .../fake_site/sites/simpletest/ignore.txt | 1 + .../tests/fixtures/fake_site/vendor/.htaccess | 1 + .../fake_site/vendor/composer/installed.json | 85 ++ .../fake_site/vendor/composer/installed.php | 56 ++ .../vendor/drupal/core-dev/composer.json | 1 + .../drupal/core-recommended/composer.json | 1 + .../vendor/drupal/core/composer.json | 41 + .../fixtures/fake_site/vendor/web.config | 1 + .../cweagans--composer-patches/composer.json | 15 + .../src/ComposerPatches.php | 29 + .../path_repos/drupal--core-dev/composer.json | 1 + .../drupal--core-recommended/composer.json | 1 + .../path_repos/drupal--core/composer.json | 41 + .../main_module_submodule/composer.json | 8 + .../tests/fixtures/post_update.php | 12 + .../release-history/aaa_update_test.1.1.xml | 201 +++++ .../fixtures/release-history/alpha.1.1.0.xml | 47 + .../release-history/drupal.10.0.0.xml | 214 +++++ .../release-history/drupal.9.8.0-alpha1.xml | 79 ++ .../release-history/drupal.9.8.0-beta1.xml | 79 ++ .../release-history/drupal.9.8.0-rc1.xml | 79 ++ .../drupal.9.8.1-empty_supported_branches.xml | 20 + .../release-history/drupal.9.8.1-extra.xml | 157 ++++ .../release-history/drupal.9.8.1-security.xml | 122 +++ ...rupal.9.8.1-supported_branches_not_set.xml | 19 + .../drupal.9.8.2-older-sec-release.xml | 216 +++++ .../drupal.9.8.2-unsupported_unpublished.xml | 237 +++++ .../fixtures/release-history/drupal.9.8.2.xml | 241 +++++ .../drupal.9.8.2_unknown_status.xml | 233 +++++ .../release-history/main_module.1.0.0.xml | 33 + .../package_manager_test_update.7.0.1.xml | 166 ++++ .../package_manager_theme.1.1.xml | 61 ++ .../release-history/semver_test.1.1.xml | 201 +++++ .../release-history/updated_module.1.1.0.xml | 47 + .../fixture_manipulator.info.yml | 4 + .../fixture_manipulator.services.yml | 7 + .../src/ActiveFixtureManipulator.php | 25 + .../src/FixtureManipulator.php | 645 +++++++++++++ .../src/ProcessFactory.php | 45 + .../src/StageFixtureManipulator.php | 109 +++ .../package_manager_bypass.info.yml | 7 + .../package_manager_bypass.services.yml | 7 + .../src/ComposerStagerExceptionTrait.php | 41 + .../src/LoggingBeginner.php | 53 ++ .../src/LoggingCommitter.php | 53 ++ .../src/LoggingDecoratorTrait.php | 47 + .../src/MockPathLocator.php | 97 ++ .../package_manager_bypass/src/NoOpStager.php | 73 ++ .../PackageManagerBypassServiceProvider.php | 50 + .../package_manager_test_api.info.yml | 6 + .../package_manager_test_api.routing.yml | 21 + .../src/ApiController.php | 152 ++++ ...package_manager_test_event_logger.info.yml | 6 + ...age_manager_test_event_logger.services.yml | 5 + .../EventSubscriber/EventLogSubscriber.php | 84 ++ ...kage_manager_test_release_history.info.yml | 7 + ...e_manager_test_release_history.routing.yml | 9 + .../src/TestController.php | 47 + .../package_manager_test_update.info.yml | 4 + .../package_manager_test_validation.info.yml | 6 + ...ckage_manager_test_validation.services.yml | 12 + .../CollectPathsToExcludeFailValidator.php | 46 + .../src/EventSubscriber/TestSubscriber.php | 177 ++++ ...geManagerTestValidationServiceProvider.php | 33 + .../src/StagedDatabaseUpdateValidator.php | 55 ++ .../tests/src/Build/PackageInstallTest.php | 90 ++ .../tests/src/Build/PackageUpdateTest.php | 74 ++ .../src/Build/TemplateProjectTestBase.php | 733 +++++++++++++++ .../Functional/ComposerRequirementTest.php | 59 ++ .../FailureMarkerRequirementTest.php | 73 ++ .../tests/src/Functional/GenericTest.php | 14 + .../AllowedScaffoldPackagesValidatorTest.php | 61 ++ ...BaseRequirementsFulfilledValidatorTest.php | 95 ++ .../tests/src/Kernel/ChangeLoggerTest.php | 99 ++ .../src/Kernel/ComposerInspectorTest.php | 560 ++++++++++++ .../ComposerMinimumStabilityValidatorTest.php | 54 ++ .../Kernel/ComposerPatchesValidatorTest.php | 292 ++++++ .../Kernel/ComposerPluginsValidatorTest.php | 405 +++++++++ .../src/Kernel/ComposerValidatorTest.php | 173 ++++ .../src/Kernel/DiskSpaceValidatorTest.php | 189 ++++ .../Kernel/DuplicateInfoFileValidatorTest.php | 242 +++++ .../Kernel/EnabledExtensionsValidatorTest.php | 163 ++++ .../EnvironmentSupportValidatorTest.php | 81 ++ .../tests/src/Kernel/FailureMarkerTest.php | 100 ++ .../tests/src/Kernel/FakeSiteFixtureTest.php | 161 ++++ .../src/Kernel/FixtureManipulatorTest.php | 280 ++++++ .../src/Kernel/InstalledPackagesListTest.php | 170 ++++ .../src/Kernel/LockFileValidatorTest.php | 217 +++++ .../src/Kernel/MultisiteValidatorTest.php | 107 +++ ...OverwriteExistingPackagesValidatorTest.php | 159 ++++ .../Kernel/PackageManagerKernelTestBase.php | 520 +++++++++++ .../Kernel/PathExcluder/GitExcluderTest.php | 140 +++ .../PathExcluder/NodeModulesExcluderTest.php | 55 ++ .../SiteConfigurationExcluderTest.php | 120 +++ .../PathExcluder/SiteFilesExcluderTest.php | 72 ++ .../SqliteDatabaseExcluderTest.php | 152 ++++ .../PathExcluder/TestSiteExcluderTest.php | 49 + .../PathExcluder/UnknownPathExcluderTest.php | 246 +++++ .../VendorHardeningExcluderTest.php | 47 + .../Kernel/PendingUpdatesValidatorTest.php | 89 ++ .../src/Kernel/PhpExtensionsValidatorTest.php | 87 ++ .../tests/src/Kernel/PhpTufValidatorTest.php | 233 +++++ .../tests/src/Kernel/ProcessFactoryTest.php | 36 + .../tests/src/Kernel/ProjectInfoTest.php | 303 +++++++ .../tests/src/Kernel/RsyncValidatorTest.php | 76 ++ .../tests/src/Kernel/ServicesTest.php | 56 ++ .../src/Kernel/SettingsValidatorTest.php | 66 ++ .../tests/src/Kernel/StageBaseTest.php | 831 +++++++++++++++++ .../tests/src/Kernel/StageEventsTest.php | 217 +++++ .../tests/src/Kernel/StageOwnershipTest.php | 242 +++++ .../Kernel/StagedDBUpdateValidatorTest.php | 207 +++++ .../tests/src/Kernel/StatusCheckTraitTest.php | 61 ++ .../Kernel/SupportedReleaseValidatorTest.php | 243 +++++ .../tests/src/Kernel/SymlinkValidatorTest.php | 203 +++++ .../src/Kernel/TranslatableStringTest.php | 43 + .../WritableFileSystemValidatorTest.php | 265 ++++++ .../src/Traits/AssertPreconditionsTrait.php | 105 +++ .../src/Traits/ComposerInstallersTrait.php | 75 ++ .../src/Traits/ComposerStagerTestTrait.php | 48 + .../src/Traits/FixtureManipulatorTrait.php | 24 + .../tests/src/Traits/FixtureUtilityTrait.php | 83 ++ .../src/Traits/InstalledPackagesListTrait.php | 46 + .../Traits/PackageManagerBypassTestTrait.php | 43 + .../tests/src/Traits/ValidationTestTrait.php | 130 +++ .../tests/src/Unit/ExecutableFinderTest.php | 39 + .../tests/src/Unit/InstalledPackageTest.php | 67 ++ .../src/Unit/InstalledPackagesListTest.php | 165 ++++ .../tests/src/Unit/LoggingBeginnerTest.php | 58 ++ .../tests/src/Unit/LoggingCommitterTest.php | 61 ++ .../tests/src/Unit/LoggingStagerTest.php | 51 ++ .../tests/src/Unit/PathLocatorTest.php | 123 +++ .../src/Unit/ProcessOutputCallbackTest.php | 133 +++ .../tests/src/Unit/RequireEventTraitTest.php | 69 ++ .../tests/src/Unit/StageBaseTest.php | 151 ++++ .../Unit/StageNotInActiveValidatorTest.php | 117 +++ .../tests/src/Unit/ValidationResultTest.php | 175 ++++ core/scripts/PackageManagerFixtureCreator.php | 113 +++ .../Template/ComposerProjectTemplatesTest.php | 2 +- 265 files changed, 24979 insertions(+), 3 deletions(-) create mode 100644 core/modules/package_manager/config/install/package_manager.settings.yml create mode 100644 core/modules/package_manager/config/schema/package_manager.schema.yml create mode 100644 core/modules/package_manager/package_manager.api.php create mode 100644 core/modules/package_manager/package_manager.info.yml create mode 100644 core/modules/package_manager/package_manager.install create mode 100644 core/modules/package_manager/package_manager.module create mode 100644 core/modules/package_manager/package_manager.services.yml create mode 100644 core/modules/package_manager/src/ComposerInspector.php create mode 100644 core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php create mode 100644 core/modules/package_manager/src/Event/PostApplyEvent.php create mode 100644 core/modules/package_manager/src/Event/PostCreateEvent.php create mode 100644 core/modules/package_manager/src/Event/PostRequireEvent.php create mode 100644 core/modules/package_manager/src/Event/PreApplyEvent.php create mode 100644 core/modules/package_manager/src/Event/PreCreateEvent.php create mode 100644 core/modules/package_manager/src/Event/PreOperationStageEvent.php create mode 100644 core/modules/package_manager/src/Event/PreRequireEvent.php create mode 100644 core/modules/package_manager/src/Event/RequireEventTrait.php create mode 100644 core/modules/package_manager/src/Event/StageEvent.php create mode 100644 core/modules/package_manager/src/Event/StatusCheckEvent.php create mode 100644 core/modules/package_manager/src/EventSubscriber/ChangeLogger.php create mode 100644 core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php create mode 100644 core/modules/package_manager/src/Exception/ApplyFailedException.php create mode 100644 core/modules/package_manager/src/Exception/ComposerNotReadyException.php create mode 100644 core/modules/package_manager/src/Exception/StageEventException.php create mode 100644 core/modules/package_manager/src/Exception/StageException.php create mode 100644 core/modules/package_manager/src/Exception/StageFailureMarkerException.php create mode 100644 core/modules/package_manager/src/Exception/StageOwnershipException.php create mode 100644 core/modules/package_manager/src/ExecutableFinder.php create mode 100644 core/modules/package_manager/src/FailureMarker.php create mode 100644 core/modules/package_manager/src/FileProcessOutputCallback.php create mode 100644 core/modules/package_manager/src/ImmutablePathList.php create mode 100644 core/modules/package_manager/src/InstalledPackage.php create mode 100644 core/modules/package_manager/src/InstalledPackagesList.php create mode 100644 core/modules/package_manager/src/LegacyVersionUtility.php create mode 100644 core/modules/package_manager/src/LoggingBeginner.php create mode 100644 core/modules/package_manager/src/LoggingCommitter.php create mode 100644 core/modules/package_manager/src/LoggingStager.php create mode 100644 core/modules/package_manager/src/PackageManagerServiceProvider.php create mode 100644 core/modules/package_manager/src/PackageManagerUninstallValidator.php create mode 100644 core/modules/package_manager/src/PackageManagerUpdateProcessor.php create mode 100644 core/modules/package_manager/src/PathExcluder/GitExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/NodeModulesExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php create mode 100644 core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php create mode 100644 core/modules/package_manager/src/PathLocator.php create mode 100644 core/modules/package_manager/src/Plugin/QueueWorker/Cleaner.php create mode 100644 core/modules/package_manager/src/ProcessFactory.php create mode 100644 core/modules/package_manager/src/ProcessOutputCallback.php create mode 100644 core/modules/package_manager/src/ProjectInfo.php create mode 100644 core/modules/package_manager/src/StageBase.php create mode 100644 core/modules/package_manager/src/StatusCheckTrait.php create mode 100644 core/modules/package_manager/src/TranslatableStringAdapter.php create mode 100644 core/modules/package_manager/src/TranslatableStringFactory.php create mode 100644 core/modules/package_manager/src/ValidationResult.php create mode 100644 core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php create mode 100644 core/modules/package_manager/src/Validator/BaseRequirementValidatorTrait.php create mode 100644 core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php create mode 100644 core/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php create mode 100644 core/modules/package_manager/src/Validator/ComposerPatchesValidator.php create mode 100644 core/modules/package_manager/src/Validator/ComposerPluginsValidator.php create mode 100644 core/modules/package_manager/src/Validator/ComposerValidator.php create mode 100644 core/modules/package_manager/src/Validator/DiskSpaceValidator.php create mode 100644 core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php create mode 100644 core/modules/package_manager/src/Validator/EnabledExtensionsValidator.php create mode 100644 core/modules/package_manager/src/Validator/EnvironmentSupportValidator.php create mode 100644 core/modules/package_manager/src/Validator/LockFileValidator.php create mode 100644 core/modules/package_manager/src/Validator/MultisiteValidator.php create mode 100644 core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php create mode 100644 core/modules/package_manager/src/Validator/PendingUpdatesValidator.php create mode 100644 core/modules/package_manager/src/Validator/PhpExtensionsValidator.php create mode 100644 core/modules/package_manager/src/Validator/PhpTufValidator.php create mode 100644 core/modules/package_manager/src/Validator/RsyncValidator.php create mode 100644 core/modules/package_manager/src/Validator/SettingsValidator.php create mode 100644 core/modules/package_manager/src/Validator/StageNotInActiveValidator.php create mode 100644 core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php create mode 100644 core/modules/package_manager/src/Validator/SupportedReleaseValidator.php create mode 100644 core/modules/package_manager/src/Validator/SymlinkValidator.php create mode 100644 core/modules/package_manager/src/Validator/WritableFileSystemValidator.php create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/README.md create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module create mode 100644 core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml create mode 100644 core/modules/package_manager/tests/fixtures/db_update.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/.gitignore create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/README.md create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/composer.lock create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/packages.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/post_update.php create mode 100644 core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml create mode 100644 core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php create mode 100644 core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php create mode 100644 core/modules/package_manager/tests/src/Build/PackageInstallTest.php create mode 100644 core/modules/package_manager/tests/src/Build/PackageUpdateTest.php create mode 100644 core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php create mode 100644 core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php create mode 100644 core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php create mode 100644 core/modules/package_manager/tests/src/Functional/GenericTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ServicesTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/StageBaseTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/StageEventsTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php create mode 100644 core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php create mode 100644 core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/PathLocatorTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/StageBaseTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/ValidationResultTest.php create mode 100644 core/scripts/PackageManagerFixtureCreator.php diff --git a/composer.lock b/composer.lock index c533cee86ba7..036994046f91 100644 --- a/composer.lock +++ b/composer.lock @@ -496,7 +496,7 @@ "dist": { "type": "path", "url": "core", - "reference": "278aa6e72e52943d0bfa149af42fd1dcc0c9a31f" + "reference": "c5a5d4ac5312bd6c16d1e8505b20ba640837b7a0" }, "require": { "asm89/stack-cors": "^2.1", @@ -525,6 +525,7 @@ "mck89/peast": "^1.14", "pear/archive_tar": "^1.4.14", "php": ">=8.3.0", + "php-tuf/composer-stager": "^2-rc5", "psr/log": "^3.0", "revolt/event-loop": "^1.0", "sebastian/diff": "^4|^5", @@ -1457,6 +1458,83 @@ }, "time": "2021-03-21T15:43:46+00:00" }, + { + "name": "php-tuf/composer-stager", + "version": "v2.0.0-rc5", + "source": { + "type": "git", + "url": "https://github.com/php-tuf/composer-stager.git", + "reference": "9c5aef2ab98db381c30fe4837e9007e1ac2b89e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-tuf/composer-stager/zipball/9c5aef2ab98db381c30fe4837e9007e1ac2b89e6", + "reference": "9c5aef2ab98db381c30fe4837e9007e1ac2b89e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1.0", + "symfony/filesystem": "^6.2 || ^7.0", + "symfony/process": "^6.2 || ^7.0", + "symfony/translation-contracts": "^3.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "infection/infection": "^0.27.0 || ^0.28.0 || ^0.29.0", + "phpbench/phpbench": "^1.2", + "phpro/grumphp-shim": "^2.0", + "phpspec/prophecy": "^1.17", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^9.6", + "rector/rector": "^0.17.5 || ^0.18.0 || ^0.19.0 || ^1.0.0", + "slevomat/coding-standard": "^8.13", + "squizlabs/php_codesniffer": "^3.7", + "symfony/config": "^6.3", + "symfony/dependency-injection": "^6.3", + "symfony/yaml": "^6.3", + "thecodingmachine/phpstan-strict-rules": "^1.0" + }, + "suggest": { + "symfony/dependency-injection": "For dependency injection", + "symfony/translation": "For internationalization tools" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "2.x-dev" + }, + "grumphp": { + "disable-plugin": true + } + }, + "autoload": { + "psr-4": { + "PhpTuf\\ComposerStager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Travis Carden", + "email": "travis.carden@gmail.com", + "role": "Developer" + } + ], + "description": "Stages Composer commands so they can be safely run on a production codebase.", + "homepage": "https://github.com/php-tuf/composer-stager", + "support": { + "issues": "https://github.com/php-tuf/composer-stager/issues", + "source": "https://github.com/php-tuf/composer-stager" + }, + "time": "2024-07-17T18:52:20+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json index 8fc8e8d20fd2..e2cc7f37e584 100644 --- a/composer/Metapackage/CoreRecommended/composer.json +++ b/composer/Metapackage/CoreRecommended/composer.json @@ -23,6 +23,7 @@ "pear/console_getopt": "~v1.4.3", "pear/pear-core-minimal": "~v1.10.15", "pear/pear_exception": "~v1.0.2", + "php-tuf/composer-stager": "~v2.0.0-rc5", "psr/cache": "~3.0.0", "psr/container": "~2.0.2", "psr/event-dispatcher": "~1.0.0", diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php index b0e07347ca85..90676fb63652 100644 --- a/core/.phpstan-baseline.php +++ b/core/.phpstan-baseline.php @@ -36324,6 +36324,342 @@ 'count' => 1, 'path' => __DIR__ . '/modules/options/tests/src/Kernel/Views/OptionsTestBase.php', ]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\ComposerInspector\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/ComposerInspector.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\ComposerInspector\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/ComposerInspector.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\EventSubscriber\\\\ChangeLogger\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/EventSubscriber/ChangeLogger.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\EventSubscriber\\\\ChangeLogger\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/EventSubscriber/ChangeLogger.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\PackageManagerUninstallValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/PackageManagerUninstallValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\PackageManagerUninstallValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/PackageManagerUninstallValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\PathExcluder\\\\UnknownPathExcluder\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\PathExcluder\\\\UnknownPathExcluder\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\StageBase\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/StageBase.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\StageBase\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/StageBase.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\AllowedScaffoldPackagesValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\AllowedScaffoldPackagesValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerMinimumStabilityValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerMinimumStabilityValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerPatchesValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerPatchesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerPatchesValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerPatchesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerPluginsValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerPluginsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerPluginsValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerPluginsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\ComposerValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/ComposerValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\DiskSpaceValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/DiskSpaceValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\DiskSpaceValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/DiskSpaceValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\DuplicateInfoFileValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\DuplicateInfoFileValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\EnabledExtensionsValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/EnabledExtensionsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\EnabledExtensionsValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/EnabledExtensionsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\EnvironmentSupportValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/EnvironmentSupportValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\EnvironmentSupportValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/EnvironmentSupportValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\LockFileValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/LockFileValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\LockFileValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/LockFileValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\MultisiteValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/MultisiteValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\MultisiteValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/MultisiteValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\OverwriteExistingPackagesValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\OverwriteExistingPackagesValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PendingUpdatesValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PendingUpdatesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PendingUpdatesValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PendingUpdatesValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PhpExtensionsValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PhpExtensionsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PhpExtensionsValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PhpExtensionsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PhpTufValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PhpTufValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\PhpTufValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/PhpTufValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\RsyncValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/RsyncValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\RsyncValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/RsyncValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\SettingsValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/SettingsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\SettingsValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/SettingsValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\StageNotInActiveValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/StageNotInActiveValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\StageNotInActiveValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/StageNotInActiveValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\StagedDBUpdateValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/StagedDBUpdateValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\StagedDBUpdateValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/StagedDBUpdateValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\SupportedReleaseValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/SupportedReleaseValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\SupportedReleaseValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/SupportedReleaseValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\WritableFileSystemValidator\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/WritableFileSystemValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\package_manager\\\\Validator\\\\WritableFileSystemValidator\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/src/Validator/WritableFileSystemValidator.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\Tests\\\\package_manager\\\\Functional\\\\FailureMarkerRequirementTest\\:\\:formatPlural\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\Tests\\\\package_manager\\\\Functional\\\\FailureMarkerRequirementTest\\:\\:getNumberOfPlurals\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\Tests\\\\package_manager\\\\Kernel\\\\StageOwnershipTest\\:\\:grantPermissions\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.return + 'message' => '#^Method Drupal\\\\Tests\\\\package_manager\\\\Kernel\\\\StageOwnershipTest\\:\\:setCurrentUser\\(\\) has no return type specified\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php', +]; $ignoreErrors[] = [ // identifier: missingType.return 'message' => '#^Function page_cache_help\\(\\) has no return type specified\\.$#', diff --git a/core/composer.json b/core/composer.json index cabc5b34a7a9..d8b918d3f8d4 100644 --- a/core/composer.json +++ b/core/composer.json @@ -49,7 +49,8 @@ "pear/archive_tar": "^1.4.14", "psr/log": "^3.0", "mck89/peast": "^1.14", - "sebastian/diff": "^4|^5" + "sebastian/diff": "^4|^5", + "php-tuf/composer-stager": "^2-rc5" }, "conflict": { "drush/drush": "<12.4.3" diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index a7dbf87ecd74..ca266d4b37cd 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -256,6 +256,7 @@ guzzlehttp hande hateoas hexcode +hhvm hilited hmac hookname @@ -568,6 +569,7 @@ subvalues subview supercede svgz +syncer synchronizable syrop tabbingmanager diff --git a/core/modules/package_manager/config/install/package_manager.settings.yml b/core/modules/package_manager/config/install/package_manager.settings.yml new file mode 100644 index 000000000000..edd2c6ed40a9 --- /dev/null +++ b/core/modules/package_manager/config/install/package_manager.settings.yml @@ -0,0 +1,5 @@ +executables: + composer: ~ + rsync: ~ +additional_trusted_composer_plugins: [] +include_unknown_files_in_project_root: false diff --git a/core/modules/package_manager/config/schema/package_manager.schema.yml b/core/modules/package_manager/config/schema/package_manager.schema.yml new file mode 100644 index 000000000000..e048fd67a738 --- /dev/null +++ b/core/modules/package_manager/config/schema/package_manager.schema.yml @@ -0,0 +1,33 @@ +package_name: + type: string + label: 'Package name' + constraints: + Regex: + # @see https://getcomposer.org/schema.json + pattern: '/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$/' + +package_manager.settings: + type: config_object + label: 'Package Manager settings' + mapping: + executables: + type: sequence + label: 'Absolute paths to required executables, or NULL to rely on PATH' + sequence: + type: string + label: 'Absolute path to executable, or NULL' + additional_trusted_composer_plugins: + type: sequence + label: 'Additional trusted composer plugins' + sequence: + type: package_name + label: 'Trusted composer plugin' + include_unknown_files_in_project_root: + type: boolean + label: 'Include unrecognized files and directories in the project root in staging operations' + log: + type: string + label: 'Path of a file to which Composer Stager process output should be logged' + constraints: + NotBlank: [] + requiredKey: false diff --git a/core/modules/package_manager/package_manager.api.php b/core/modules/package_manager/package_manager.api.php new file mode 100644 index 000000000000..28083ca7f7a4 --- /dev/null +++ b/core/modules/package_manager/package_manager.api.php @@ -0,0 +1,292 @@ +<?php + +/** + * @file + * Documentation related to Package Manager. + */ + +/** + * @defgroup package_manager_architecture Package Manager architecture + * @{ + * + * @section sec_overview Overview + * Package Manager is an API-only module which provides the scaffolding and + * functionality needed for Drupal to make changes to its own running code base + * via Composer. It doesn't have a user interface. + * + * @see https://getcomposer.org/ + * @see https://github.com/php-tuf/composer-stager + * + * @section sec_concepts Concepts + * At the center of Package Manager is the concept of a stage directory. A + * stage directory is a complete copy of the active Drupal code base, created + * in a temporary directory that isn't accessible over the web. The stage + * directory doesn't have any site-specific assets like settings.php, uploaded + * files, or SQLite databases. + * + * Only one stage directory can exist at any given time, and it is "owned" by + * the user or session that originally created it. Only the owner can perform + * operations on the stage directory, and only using the same class (i.e., + * \Drupal\package_manager\StageBase or a subclass) they used to create it. + * + * Package Manager can run Composer commands in the stage directory to require + * or update packages in it, and then copy those changes back into the live, + * running code base (which is referred to as the "active directory"). The + * stage directory can then be safely deleted. Four distinct operations: + * create, require, apply, and destroy. They comprise the "stage life cycle." + * + * Package Manager's \Drupal\package_manager\StageBase controls the stage life + * cycle and is an abstract class that must be subclassed. Most of the time, + * there should be little need to heavily customize a StageBase subclass; + * custom code should generally use the event system to interact with the stage. + * + * @see sec_stage_events Stage API: Events + * Events are dispatched before and after each operation in the stage life + * cycle. There are two types of events: pre-operation and post-operation. + * Pre-operation event subscribers can analyze the state of the stage directory, + * or the system at large, and flag errors if they detect any problems. If + * errors are flagged, the operation is prevented. Therefore, pre-operation + * events are helpful to ensure that the stage directory is in a valid state. + * Post-operation events are simple triggers allowing custom code to react when + * an operation is successfully completed. They cannot flag errors to block + * stage operations (although they can use the core messenger and logging + * systems as needed). + * + * All stage events extend \Drupal\package_manager\Event\StageEvent, and all + * pre-operation events extend + * \Drupal\package_manager\Event\PreOperationStageEvent. All events have a + * $stage property which allows access to the stage object itself. + * + * The stage dispatches the following events during its life cycle: + * + * - \Drupal\package_manager\Event\PreCreateEvent + * Dispatched before the stage directory is created. At this point, the + * stage will have recorded which user or session owns it, so another stage + * directory cannot be created until the current one is destroyed. If + * subscribers flag errors during this event, the stage will release its + * ownership. This is the earliest possible time to detect problems that might + * prevent the stage from completing its life cycle successfully. This event + * is dispatched only once during the stage life cycle. + * @see sec_stage_exceptions + * + * - \Drupal\package_manager\Event\PostCreateEvent + * Dispatched after the stage directory has been created, which means that the + * running Drupal code base has been copied into a separate, temporary + * location. This event is dispatched only once during the stage life cycle. + * + * - \Drupal\package_manager\Event\PreRequireEvent + * Dispatched before one or more Composer packages are required into the + * stage directory. This event may be dispatched multiple times during the + * stage life cycle, and receives a list of the packages which are about to + * be required into the stage directory. The list of packages CANNOT be + * altered by subscribers. + * + * - \Drupal\package_manager\Event\PostRequireEvent + * Dispatched after one or more Composer packages have been added to the + * stage directory. This event may be dispatched multiple times during the + * stage life cycle, and receives a list of the packages which were required + * into the stage directory. (Note that this is a list of packages which + * were specifically *asked for*, not the full list of packages and + * dependencies that was actually installed.) + * + * - \Drupal\package_manager\Event\PreApplyEvent + * Dispatched before changes in the stage directory (i.e., new and/or updated + * packages) are copied to the active directory. This is the final opportunity + * for event subscribers to flag errors before the active directory is + * modified, because once that has happened, the changes cannot be undone. + * This event may be dispatched multiple times during the stage life cycle. + * + * - \Drupal\package_manager\Event\PostApplyEvent + * Dispatched after changes in the stage directory have been copied to the + * active directory. It should only be used for cleaning up after other + * operations that happened during the stage life cycle. For example, a + * PostCreateEvent subscriber might have set a state value which is no longer + * needed once the stage has been applied to the active directory -- in such a + * case, a PostApplyEvent subscriber should delete that value. + * `drupal_flush_all_caches()` is called just before this event is dispatched, + * so subscribers shouldn't need to flush any caches or rebuild the service + * container. This event may be dispatched multiple times during the stage + * life cycle, and should *never* be used for schema changes (i.e., operations + * that should happen in `hook_update_N()` or a post-update function). + * + * @section sec_stage_api Stage API: Public methods + * The public API of any stage consists of the following methods: + * + * - \Drupal\package_manager\StageBase::create() + * Creates the stage directory, records ownership, and dispatches pre- and + * post-create events. Returns a unique token which calling code must use to + * verify stage ownership before performing operations on the stage + * directory in subsequent requests (when the stage directory is created, + * its ownership is automatically verified for the duration of the current + * request). See \Drupal\package_manager\StageBase::claim() for more + * information. + * + * - \Drupal\package_manager\StageBase::require() + * Adds and/or updates packages in the stage directory and dispatches pre- + * and post-require events. The stage must be claimed by its owner to call + * this method. + * + * - \Drupal\package_manager\StageBase::apply() + * Copies changes from the stage directory into the active directory, and + * dispatches the pre-apply event. The stage must be claimed by its owner to + * call this method. + * + * - \Drupal\package_manager\StageBase::postApply() + * Performs post-apply tasks after changes have been copied from the stage + * directory. This method should be called as soon as possible in a new + * request because the code on disk may no longer match what has been loaded + * into PHP's runtime memory. This method clears all Drupal caches, rebuilds + * the service container, and dispatches the post-apply event. The stage must + * be claimed by its owner to call this method. + * + * - \Drupal\package_manager\StageBase::destroy() + * Destroys the stage directory, and releases ownership. It is possible to + * destroy the stage without having claimed it first, but this shouldn't be + * done unless absolutely necessary. + * + * - \Drupal\package_manager\StageBase::stageDirectoryExists() + * Determines if the stage directory exists and returns a boolean accordingly. + * This allows validators to directly know if the stage directory exists + * without using \Drupal\package_manager\StageBase::getStageDirectory(), which + * throws an exception if the stage directory does not exist. + * + * - \Drupal\package_manager\StageBase::getStageDirectory() + * Returns the absolute path of the directory where changes should be staged. + * It throws an exception if the stage hasn't been created or claimed yet. + * + * - \Drupal\package_manager\StageBase::isApplying() + * Determines if the staged changes are being applied to the active directory. + * It will return FALSE if more than an hour has passed since the apply + * operation began. + * + * - \Drupal\package_manager\StageBase::isAvailable() + * Determines if a stage directory can be created. + * + * @section sec_stage_exceptions Stage life cycle exceptions + * If problems occur during any point of the stage life cycle, a + * \Drupal\package_manager\Exception\StageException is thrown. If problems are + * detected during one of the "pre" operations, a subclass of that is thrown: + * \Drupal\package_manager\Exception\StageEventException. This will contain + * \Drupal\package_manager\ValidationResult objects. + * + * Package Manager does not catch or handle these exceptions: they provide a + * framework for other modules to build user experiences for installing, + * updating, and removing packages. + * + * @section sec_validators_status_checks API: Validators and status checks + * Package Manager requires certain conditions in order to function properly. + * Event subscribers which check such conditions should ensure that they run + * before \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator, + * by using a priority higher than BaseRequirementsFulfilledValidator::PRIORITY. + * BaseRequirementsFulfilledValidator will stop event propagation if any errors + * have been flagged by the subscribers that ran before it. + * + * The following base requirements are checked by Package Manager: + * + * - Package Manager has not been explicitly disabled in the current + * environment. + * - The Composer executable is available. + * - The detected version of Composer is supported. + * - composer.json and composer.lock exist in the project root, and are valid + * according to the @code composer validate @endcode command. + * - The stage directory is not a subdirectory of the active directory. + * - There is enough free disk space to do stage operations. + * - The Drupal site root and vendor directory are writable. + * - The current site is not part of a multisite. + * - The project root and stage directory don't contain any unsupported links. + * See https://github.com/php-tuf/composer-stager/tree/develop/src/Domain/Service/Precondition#symlinks + * for information about which types of symlinks are supported. + * + * Apart from base requirements, Package Manager also enforces certain + * constraints at various points of the stage life cycle (typically + * \Drupal\package_manager\Event\PreCreateEvent and/or + * \Drupal\package_manager\Event\PreApplyEvent), to ensure that both the active + * directory and stage directory are kept in a safe, consistent state: + * + * - If the composer.lock file is changed (e.g., by installing or updating a + * package) in the active directory after a stage directory has been created, + * Package Manager will refuse to make any further changes to the stage + * directory or apply the staged changes to the active directory. + * - Composer plugins are able to perform arbitrary file system operations, and + * hence could perform actions that make it impossible for Package Manager to + * guarantee the Drupal site will continue to work correctly. For that reason, + * Package Manager will refuse to make any further changes if untrusted + * Composer plugins are installed or staged. If you know what you are doing, + * it is possible to trust additional Composer plugins by modifying + * package_manager.settings's "additional_trusted_composer_plugins" setting. + * - The Drupal site must not have any pending database updates (i.e., + * update.php needs to be run). + * - Composer must use HTTPS to download packages and metadata (i.e., Composer's + * secure-http configuration option must be enabled). This is the default + * behavior. + * + * Package Manager also assumes certain things that it does not explicitly + * enforce or check: + * + * - Only Composer operations should be performed on the stage directory. If + * other file operations were performed, any newly created files might not + * be copied back to the active site because of + * \Drupal\package_manager\PathExcluder\UnknownPathExcluder. + * + * Event subscribers which enforce these and other constraints are referred to + * as validators. + * + * \Drupal\package_manager\Event\StatusCheckEvent may be dispatched at any time + * to check the status of the Drupal site and whether Package Manager can + * function properly. Package Manager does NOT dispatch this event on its own + * because it doesn't have a UI; it is meant for modules that build on top of + * Package Manager to ensure they will work correctly before they try to do any + * stage operations, and present errors however they want in their own UIs. + * Status checks can be dispatched irrespective of whether a stage directory has + * actually been created. + * + * In general, validators should always listen to + * \Drupal\package_manager\Event\StatusCheckEvent, + * \Drupal\package_manager\Event\PreCreateEvent, and + * \Drupal\package_manager\Event\PreApplyEvent. If they detect any errors, + * they should call the event's ::addError() method to prevent the stage life + * cycle from proceeding any further. If a validator encounters an exception, + * it can use ::addErrorFromThrowable() instead of ::addError(). During status + * checks, validators can call ::addWarning() for less severe problems -- + * warnings will NOT stop the stage life cycle. All three are convenience + * methods for equivalent \Drupal\package_manager\ValidationResult constructors, + * which can then be added to the event using ::addResult(). + * + * @see \Drupal\package_manager\ValidationResult + * @see \Drupal\package_manager\Event\PreOperationStageEvent::addError() + * @see \Drupal\package_manager\Event\PreOperationStageEvent::addErrorFromThrowable() + * @see \Drupal\package_manager\Event\StatusCheckEvent::addWarning() + * @see \Drupal\package_manager\Event\PreOperationStageEvent::addResult() + * + * @section sec_excluded_paths Excluding files from stage operations + * Certain files are never copied into the stage directory because they are + * irrelevant to Composer or Package Manager. Examples include settings.php + * and related files, public and private files, SQLite databases, and git + * repositories. Custom code can subscribe to + * Drupal\package_manager\Event\CollectPathsToExcludeEvent to flag paths which + * should never be copied into the stage directory from the active directory or + * vice versa. + * + * @see \Drupal\package_manager\Event\CollectPathsToExcludeEvent + * + * @section sec_services Useful services + * The following services are especially useful to validators: + * - \Drupal\package_manager\PathLocator looks up certain important paths in the + * active directory, such as the vendor directory, the project root and the + * web root. + * - \Drupal\package_manager\ComposerInspector is a wrapper to interact with + * Composer at the command line and get information from it about the + * project's `composer.json`, which packages are installed, etc. + * + * @section sec_package_manager_failure_marker Package Manager failure marker + * A file PACKAGE_MANAGER_FAILURE.yml is placed in the active directory while + * staged code is copied back into it, and then removed after the copying is + * finished. If this file exists, it means that the staged changes failed to be + * applied to the active directory (for example: a file system error, or the + * copying process was interrupted), and the site is therefore in an + * indeterminate state. The only thing you can do is to restore the code and + * database from a backup. + * @see \Drupal\package_manager\FailureMarker + * + * @} + */ diff --git a/core/modules/package_manager/package_manager.info.yml b/core/modules/package_manager/package_manager.info.yml new file mode 100644 index 000000000000..df4037bdb6a9 --- /dev/null +++ b/core/modules/package_manager/package_manager.info.yml @@ -0,0 +1,8 @@ +name: 'Package Manager' +type: module +description: 'API module providing functionality to stage package installs and updates with Composer.' +package: Core +version: VERSION +lifecycle: experimental +dependencies: + - drupal:update diff --git a/core/modules/package_manager/package_manager.install b/core/modules/package_manager/package_manager.install new file mode 100644 index 000000000000..4623b7567fc9 --- /dev/null +++ b/core/modules/package_manager/package_manager.install @@ -0,0 +1,71 @@ +<?php + +/** + * @file + * Contains install and update functions for Package Manager. + */ + +declare(strict_types=1); + +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * Implements hook_requirements(). + */ +function package_manager_requirements(string $phase): array { + $requirements = []; + // If we're able to check for the presence of the failure marker at all, do it + // irrespective of the current run phase. If the failure marker is there, the + // site is in an indeterminate state and should be restored from backup ASAP. + $service_id = FailureMarker::class; + if (\Drupal::hasService($service_id)) { + try { + \Drupal::service($service_id)->assertNotExists(NULL); + } + catch (StageFailureMarkerException $exception) { + $requirements['package_manager_failure_marker'] = [ + 'title' => t('Failed Package Manager update detected'), + 'description' => $exception->getMessage(), + 'severity' => REQUIREMENT_ERROR, + ]; + } + } + + if ($phase !== 'runtime') { + return $requirements; + } + /** @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $executable_finder */ + $executable_finder = \Drupal::service(ExecutableFinderInterface::class); + + // Report the Composer version in use, as well as its path. + $title = t('Composer version'); + try { + $requirements['package_manager_composer'] = [ + 'title' => $title, + 'description' => t('@version (<code>@path</code>)', [ + '@version' => \Drupal::service(ComposerInspector::class)->getVersion(), + '@path' => $executable_finder->find('composer'), + ]), + 'severity' => REQUIREMENT_INFO, + ]; + } + catch (\Throwable $e) { + // All Composer Stager exceptions are translatable. + $message = $e instanceof ExceptionInterface + ? $e->getTranslatableMessage() + : $e->getMessage(); + + $requirements['package_manager_composer'] = [ + 'title' => $title, + 'description' => t('Composer was not found. The error message was: @message', [ + '@message' => $message, + ]), + 'severity' => REQUIREMENT_ERROR, + ]; + } + return $requirements; +} diff --git a/core/modules/package_manager/package_manager.module b/core/modules/package_manager/package_manager.module new file mode 100644 index 000000000000..de607e6a9525 --- /dev/null +++ b/core/modules/package_manager/package_manager.module @@ -0,0 +1,107 @@ +<?php + +/** + * @file + * Contains hook implementations for Package Manager. + */ + +declare(strict_types=1); + +use Drupal\package_manager\ComposerInspector; + +/** + * Implements hook_help(). + */ +function package_manager_help($route_name): ?string { + switch ($route_name) { + case 'help.page.package_manager': + $output = '<h3 id="package-manager-about">' . t('About') . '</h3>'; + $output .= '<p>' . t('Package Manager is a framework for updating Drupal core and installing contributed modules and themes via Composer. It has no user interface, but it provides an API for creating a temporary copy of the current site, making changes to the copy, and then syncing those changes back into the live site.') . '</p>'; + $output .= '<p>' . t('Package Manager dispatches events before and after various operations, and external code can integrate with it by subscribing to those events. For more information, see <code>package_manager.api.php</code>.') . '</p>'; + + $output .= '<h3 id="package-manager-requirements">' . t('Requirements') . '</h3>'; + $output .= '<ul>'; + $output .= ' <li>' . t("The Drupal application's codebase must be writable in order to use Automatic Updates. This includes Drupal core, modules, themes and the Composer dependencies in the <code>vendor</code> directory. This makes Automatic Updates incompatible with some hosting platforms.") . '</li>'; + $output .= ' <li>' . t('Package Manager requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it.', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</li>'; + $output .= ' <li>' . t("Your Drupal site's <code>composer.json</code> file must be valid according to <code>composer validate</code>. See <a href=\":url\">Composer's documentation</a> for more information.", [':url' => 'https://getcomposer.org/doc/03-cli.md#validate']) . '</li>'; + $output .= ' <li>' . t('Composer must be configured for secure downloads. This means that <a href=":disable-tls">the <code>disable-tls</code> option</a> must be <code>false</code>, and <a href=":secure-http">the <code>secure-http</code> option</a> must be <code>true</code> in the <code>config</code> section of your <code>composer.json</code> file. If these options are not set in your <code>composer.json</code>, Composer will behave securely by default. To set these values at the command line, run the following commands:', [ + ':disable-tls' => 'https://getcomposer.org/doc/06-config.md#disable-tls', + ':secure-http' => 'https://getcomposer.org/doc/06-config.md#secure-http', + ]); + $output .= '<pre><code>'; + $output .= "composer config --unset disable-tls\n"; + $output .= "composer config --unset secure-http\n"; + $output .= '</code></pre></li></ul>'; + + $output .= '<h3 id="package-manager-limitations">' . t('Limitations') . '</h3>'; + $output .= '<p>' . t("Because Package Manager modifies the current site's code base, it is intentionally limited in certain ways to prevent unexpected changes to the live site:") . '</p>'; + $output .= '<ul>'; + $output .= ' <li>' . t('It does not support Drupal multi-site installations.') . '</li>'; + $output .= ' <li>' . t('It only allows supported Composer plugins. If you have any, see <a href="#package-manager-faq-unsupported-composer-plugin">What if it says I have unsupported Composer plugins in my codebase?</a>.') . '</li>'; + $output .= ' <li>' . t('It does not automatically perform version control operations, e.g., with Git. Site administrators are responsible for committing updates.') . '</li>'; + $output .= ' <li>' . t('It can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>'; + $output .= ' <li>' . t('It associates the temporary copy of the site with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>'; + $output .= ' <li>' . t('It does not allow modules to be uninstalled while syncing changes into live site.') . '</li>'; + $output .= '</ul>'; + $output .= '<p>' . t('For more information, see the <a href=":url">online documentation for the Package Manager module</a>.', [':url' => 'https://www.drupal.org/docs/8/core/modules/package-manager']) . '</p>'; + + $output .= '<h3 id="package-manager-faq">' . t('FAQ') . '</h3>'; + $output .= '<h4 id="package-manager-composer-related-faq">' . t('FAQs related to Composer') . '</h4>'; + $output .= '<ul>'; + $output .= ' <li>' . t('What if it says the <code>proc_open()</code> function is disabled on your PHP installation?'); + $output .= ' <p>' . t('Ask your system administrator to remove <code>proc_open()</code> from the <a href=":url">disable_functions</a> setting in <code>php.ini</code>.', [':url' => 'https://www.php.net/manual/en/ini.core.php#ini.disable-functions']) . '</p>'; + $output .= ' </li>'; + $output .= ' <li>' . t('What if it says the <code>composer</code> executable cannot be found?'); + $output .= ' <p>' . t("If the <code>composer</code> executable's path cannot be automatically determined, it can be explicitly set by adding the following line to <code>settings.php</code>:") . '</p>'; + $output .= " <pre><code>\$config['package_manager.settings']['executables']['composer'] = '/full/path/to/composer.phar';</code></pre>"; + $output .= ' </li>'; + $output .= ' <li>' . t('What if it says the detected version of Composer is not supported?'); + $output .= ' <p>' . t('The version of the <code>composer</code> executable must satisfy <code>@version</code>. See the <a href=":url">the Composer documentation</a> for more information, or use this command to update Composer:', ['@version' => ComposerInspector::SUPPORTED_VERSION, ':url' => 'https://getcomposer.org/doc/03-cli.md#self-update-selfupdate']) . '</p>'; + $output .= ' <pre><code>composer self-update</code></pre>'; + $output .= ' </li>'; + $output .= ' <li>' . t('What if it says the <code>composer validate</code> command failed?'); + $output .= ' <p>' . t('Composer detected problems with your <code>composer.json</code> and/or <code>composer.lock</code> files, and the project is not in a completely valid state. See <a href=":url">the Composer documentation</a> for more information.', [':url' => 'https://getcomposer.org/doc/04-schema.md']) . '</p>'; + $output .= ' </li>'; + $output .= '</ul>'; + + $output .= '<h4 id="package-manager-faq-rsync">' . t('Using rsync') . '</h4>'; + $output .= '<p>' . t('Package Manager must be able to run <code>rsync</code> to copy files between the live site and the stage directory. Package Manager will try to detect the path to <code>rsync</code>, but if it cannot be detected, you can set it explicitly by adding the following line to <code>settings.php</code>:') . '</p>'; + $output .= "<pre><code>\$config['package_manager.settings']['executables']['rsync'] = '/full/path/to/rsync';</code></pre>"; + + $output .= '<h4 id="package-manager-tuf-info">' . t('Enabling PHP-TUF protection') . '</h4>'; + $output .= '<p>' . t('Package Manager requires <a href=":php-tuf">PHP-TUF</a>, which implements <a href=":tuf">The Update Framework</a> as a way to help secure Composer package downloads via the <a href=":php-tuf-plugin">PHP-TUF Composer integration plugin</a>. This plugin must be installed and configured properly in order to use Package Manager.', [ + ':php-tuf' => 'https://github.com/php-tuf/php-tuf', + ':tuf' => 'https://theupdateframework.io/', + ':php-tuf-plugin' => 'https://github.com/php-tuf/composer-integration', + ]) . '</p>'; + $output .= '<p>' . t('To install and configure the plugin as needed, you can run the following commands:') . '</p>'; + $output .= '<pre><code>'; + $output .= "composer config allow-plugins.php-tuf/composer-integration true\n"; + $output .= "composer require php-tuf/composer-integration"; + $output .= '</code></pre>'; + $output .= '<p>' . t('Package Manager currently requires the <code>https://packages.drupal.org/8</code> Composer repository to be protected by TUF. To set this up, run the following command:') . '</p>'; + $output .= '<pre><code>'; + $output .= "composer tuf:protect https://packages.drupal.org/8\n"; + $output .= '</code></pre>'; + + $output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . t('What if it says I have unsupported Composer plugins in my codebase?') . '</h4>'; + $output .= '<p>' . t('A fresh Drupal installation only uses supported Composer plugins, but some modules or themes may depend on additional Composer plugins. <a href=":new-issue">Create a new issue</a> when you encounter this.', [ + ':new-issue' => 'https://www.drupal.org/node/add/project-issue/auto_updates', + ]) . '</p>'; + $output .= '<p>' . t('It is possible to <em>trust</em> additional Composer plugins, but this requires significant expertise: understanding the code of that Composer plugin, what the effects on the file system are and how it affects the Package Manager module. Some Composer plugins could result in a broken site!') . '</p>'; + + $output .= '<h4 id="package-manager-faq-composer-patches-installed-or-removed">' . t('What if it says <code>cweagans/composer-patches</code> cannot be installed/removed?') . '</h4>'; + $output .= '<p>' . t('Installation or removal of <code>cweagans/composer-patches</code> via Package Manager is not supported. You can install or remove it manually by running Composer commands in your site root.') . '</p>'; + $output .= '<p>' . t('To install it:') . '</p>'; + $output .= '<pre><code>composer require cweagans/composer-patches</code></pre>'; + $output .= '<p>' . t('To remove it:') . '</p>'; + $output .= '<pre><code>composer remove cweagans/composer-patches</code></pre>'; + + $output .= '<h4 id="package-manager-faq-composer-patches-not-a-root-dependency">' . t('What if it says <code>cweagans/composer-patches</code> must be a root dependency?') . '</h4>'; + $output .= '<p>' . t('If <code>cweagans/composer-patches</code> is installed, it must be defined as a dependency of the main project (i.e., it must be listed in the <code>require</code> or <code>require-dev</code> section of <code>composer.json</code>). You can run the following command in your site root to add it as a dependency of the main project:') . '</p>'; + $output .= "<pre><code>composer require cweagans/composer-patches</code></pre>"; + + return $output; + } + return NULL; +} diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml new file mode 100644 index 000000000000..e2ce375cb547 --- /dev/null +++ b/core/modules/package_manager/package_manager.services.yml @@ -0,0 +1,107 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + # Underlying Symfony utilities for Composer Stager. + Symfony\Component\Filesystem\Filesystem: + public: false + Symfony\Component\Process\ExecutableFinder: + public: false + + # Basic infrastructure services for Composer Stager, overridden by us to + # provide additional functionality. + Drupal\package_manager\ExecutableFinder: + public: false + decorates: 'PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface' + Drupal\package_manager\ProcessFactory: + public: false + decorates: 'PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface' + Drupal\package_manager\TranslatableStringFactory: + public: false + decorates: 'PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface' + PhpTuf\ComposerStager\API\FileSyncer\Service\FileSyncerInterface: + factory: ['@PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface', 'create'] + Drupal\package_manager\LoggingBeginner: + public: false + decorates: 'PhpTuf\ComposerStager\API\Core\BeginnerInterface' + Drupal\package_manager\LoggingStager: + public: false + decorates: 'PhpTuf\ComposerStager\API\Core\StagerInterface' + Drupal\package_manager\LoggingCommitter: + public: false + decorates: 'PhpTuf\ComposerStager\API\Core\CommitterInterface' + logger.channel.package_manager: + parent: logger.channel_base + arguments: + - 'package_manager' + logger.channel.package_manager_change_log: + parent: logger.channel_base + arguments: + - 'package_manager_change_log' + + # Services provided to Drupal by Package Manager. + Drupal\package_manager\PathLocator: + arguments: + $appRoot: '%app.root%' + Drupal\package_manager\FailureMarker: {} + Drupal\package_manager\EventSubscriber\UpdateDataSubscriber: {} + Drupal\package_manager\EventSubscriber\ChangeLogger: + calls: + - [setLogger, ['@logger.channel.package_manager_change_log']] + Drupal\package_manager\ComposerInspector: {} + + # Validators. + Drupal\package_manager\Validator\EnvironmentSupportValidator: {} + Drupal\package_manager\Validator\ComposerValidator: {} + Drupal\package_manager\Validator\DiskSpaceValidator: {} + Drupal\package_manager\Validator\PendingUpdatesValidator: + arguments: + - '%app.root%' + - '@update.post_update_registry' + autowire: false + Drupal\package_manager\Validator\LockFileValidator: + arguments: + $keyValueFactory: '@keyvalue' + Drupal\package_manager\Validator\WritableFileSystemValidator: {} + Drupal\package_manager\Validator\ComposerMinimumStabilityValidator: {} + Drupal\package_manager\Validator\MultisiteValidator: {} + Drupal\package_manager\Validator\SymlinkValidator: {} + Drupal\package_manager\Validator\DuplicateInfoFileValidator: {} + Drupal\package_manager\Validator\EnabledExtensionsValidator: {} + Drupal\package_manager\Validator\OverwriteExistingPackagesValidator: {} + Drupal\package_manager\Validator\AllowedScaffoldPackagesValidator: {} + Drupal\package_manager\Validator\StagedDBUpdateValidator: {} + Drupal\package_manager\PathExcluder\TestSiteExcluder: {} + Drupal\package_manager\PathExcluder\VendorHardeningExcluder: {} + Drupal\package_manager\PathExcluder\SiteFilesExcluder: + arguments: + $wrappers: [public, private, assets] + Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder: {} + Drupal\package_manager\PathExcluder\GitExcluder: {} + Drupal\package_manager\PathExcluder\UnknownPathExcluder: {} + Drupal\package_manager\PathExcluder\SiteConfigurationExcluder: + arguments: + $sitePath: '%site.path%' + Drupal\package_manager\PathExcluder\NodeModulesExcluder: {} + Drupal\package_manager\PackageManagerUninstallValidator: + arguments: + $eventDispatcher: '@event_dispatcher' + Drupal\package_manager\Validator\SettingsValidator: {} + Drupal\package_manager\Validator\RsyncValidator: {} + Drupal\package_manager\Validator\ComposerPluginsValidator: {} + Drupal\package_manager\Validator\ComposerPatchesValidator: {} + Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator: {} + Drupal\package_manager\Validator\SupportedReleaseValidator: {} + Drupal\package_manager\Validator\StageNotInActiveValidator: {} + Drupal\package_manager\Validator\PhpExtensionsValidator: {} + Drupal\package_manager\Validator\PhpTufValidator: + arguments: + $repositories: + - 'https://packages.drupal.org/8' + Drupal\package_manager\PackageManagerUpdateProcessor: + arguments: + # @todo Autowire $update_fetcher when https://drupal.org/i/3325557 lands. + $update_fetcher: '@update.fetcher' + $key_value_factory: '@keyvalue' + $key_value_expirable_factory: '@keyvalue.expirable' diff --git a/core/modules/package_manager/src/ComposerInspector.php b/core/modules/package_manager/src/ComposerInspector.php new file mode 100644 index 000000000000..69d307388503 --- /dev/null +++ b/core/modules/package_manager/src/ComposerInspector.php @@ -0,0 +1,481 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\Semver\Semver; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Exception\ComposerNotReadyException; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Exception\RuntimeException; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +/** + * Defines a class to get information from Composer. + * + * This is a PHP wrapper to facilitate interacting with composer and: + * - list installed packages: getInstalledPackagesList() (`composer show`) + * - validate composer state & project: validate() (`composer validate`) + * - read project & package configuration: getConfig() (`composer config`) + * - read root package info: getRootPackageInfo() (`composer show --self`) + */ +class ComposerInspector implements LoggerAwareInterface { + + use LoggerAwareTrait { + setLogger as traitSetLogger; + } + use StringTranslationTrait; + + /** + * The process output callback. + * + * @var \Drupal\package_manager\ProcessOutputCallback + */ + private ProcessOutputCallback $processCallback; + + /** + * Statically cached installed package lists, keyed by directory. + * + * @var \Drupal\package_manager\InstalledPackagesList[] + */ + private array $packageLists = []; + + /** + * A semantic version constraint for the supported version(s) of Composer. + * + * @see https://endoflife.date/composer + * + * @var string + */ + final public const SUPPORTED_VERSION = '^2.6'; + + public function __construct( + private readonly ComposerProcessRunnerInterface $runner, + private readonly ComposerIsAvailableInterface $composerIsAvailable, + private readonly PathFactoryInterface $pathFactory, + ) { + $this->processCallback = new ProcessOutputCallback(); + $this->setLogger(new NullLogger()); + } + + /** + * {@inheritdoc} + */ + public function setLogger(LoggerInterface $logger): void { + $this->traitSetLogger($logger); + $this->processCallback->setLogger($logger); + } + + /** + * Checks that Composer commands can be run. + * + * @param string $working_dir + * The directory in which Composer will be run. + * + * @see ::validateExecutable() + * @see ::validateProject() + */ + public function validate(string $working_dir): void { + $this->validateExecutable(); + $this->validateProject($working_dir); + } + + /** + * Checks that `composer.json` is valid and `composer.lock` exists. + * + * @param string $working_dir + * The directory to check. + * + * @throws \Drupal\package_manager\Exception\ComposerNotReadyException + * Thrown if: + * - `composer.json` doesn't exist in the given directory or is invalid + * according to `composer validate`. + * - `composer.lock` doesn't exist in the given directory. + */ + private function validateProject(string $working_dir): void { + $messages = []; + $previous_exception = NULL; + + // If either composer.json or composer.lock have changed, ensure the + // directory is in a completely valid state, according to Composer. + if ($this->invalidateCacheIfNeeded($working_dir)) { + try { + $this->runner->run([ + 'validate', + '--check-lock', + '--no-check-publish', + '--with-dependencies', + '--no-ansi', + "--working-dir=$working_dir", + ]); + } + catch (RuntimeException $e) { + $messages[] = $e->getMessage(); + $previous_exception = $e; + } + } + + // Check for the presence of composer.lock, because `composer validate` + // doesn't expect it to exist, but we do (see ::getInstalledPackagesList()). + if (!file_exists($working_dir . DIRECTORY_SEPARATOR . 'composer.lock')) { + $messages[] = $this->t('composer.lock not found in @dir.', [ + '@dir' => $working_dir, + ]); + } + + if ($messages) { + throw new ComposerNotReadyException($working_dir, $messages, 0, $previous_exception); + } + } + + /** + * Validates that the Composer executable exists in a supported version. + * + * @throws \Exception + * Thrown if the Composer executable is not available or the detected + * version of Composer is not supported. + */ + private function validateExecutable(): void { + $messages = []; + + // Ensure the Composer executable is available. For performance reasons, + // statically cache the result, since it's unlikely to change during the + // current request. If $unavailable_message is NULL, it means we haven't + // done this check yet. If it's FALSE, it means we did the check and there + // were no errors; and, if it's a string, it's the error message we received + // the last time we did this check. + static $unavailable_message; + if ($unavailable_message === NULL) { + try { + // The "Composer is available" precondition requires active and stage + // directories, but they don't actually matter to it, nor do path + // exclusions, so dummies can be passed for simplicity. + $active_dir = $this->pathFactory->create(__DIR__); + $stage_dir = $active_dir; + + $this->composerIsAvailable->assertIsFulfilled($active_dir, $stage_dir); + $unavailable_message = FALSE; + } + catch (PreconditionException $e) { + $unavailable_message = $e->getMessage(); + } + } + if ($unavailable_message) { + $messages[] = $unavailable_message; + } + + // The detected version of Composer is unlikely to change during the + // current request, so statically cache it. If $unsupported_message is NULL, + // it means we haven't done this check yet. If it's FALSE, it means we did + // the check and there were no errors; and, if it's a string, it's the error + // message we received the last time we did this check. + static $unsupported_message; + if ($unsupported_message === NULL) { + try { + $detected_version = $this->getVersion(); + + if (Semver::satisfies($detected_version, static::SUPPORTED_VERSION)) { + // We did the version check, and it did not produce an error message. + $unsupported_message = FALSE; + } + else { + $unsupported_message = $this->t('The detected Composer version, @version, does not satisfy <code>@constraint</code>.', [ + '@version' => $detected_version, + '@constraint' => static::SUPPORTED_VERSION, + ]); + } + } + catch (\UnexpectedValueException $e) { + $unsupported_message = $e->getMessage(); + } + } + if ($unsupported_message) { + $messages[] = $unsupported_message; + } + + if ($messages) { + throw new ComposerNotReadyException(NULL, $messages); + } + } + + /** + * Returns a config value from Composer. + * + * @param string $key + * The config key to get. + * @param string $context + * The path of either the directory in which to run Composer, or a specific + * configuration file (such as a particular package's `composer.json`) from + * which to read specific values. + * + * @return string|null + * The output data. Note that the caller must know the shape of the + * requested key's value: if it's a string, no further processing is needed, + * but if it is a boolean, an array or a map, JSON decoding should be + * applied. + * + * @see ::getAllowPluginsConfig() + * @see \Composer\Command\ConfigCommand::execute() + */ + public function getConfig(string $key, string $context): ?string { + $this->validateExecutable(); + + $command = ['config', $key]; + // If we're consulting a specific file for the config value, we don't need + // to validate the project as a whole. + if (is_file($context)) { + $command[] = "--file={$context}"; + } + else { + $this->validateProject($context); + $command[] = "--working-dir={$context}"; + } + try { + $this->runner->run($command, callback: $this->processCallback->reset()); + } + catch (RuntimeException $e) { + // Assume any error from `composer config` is about an undefined key-value + // pair which may have a known default value. + return match ($key) { + 'extra' => '{}', + default => throw $e, + }; + } + $output = $this->processCallback->getOutput(); + return $output ? trim(implode('', $output)) : NULL; + } + + /** + * Returns the current Composer version. + * + * @return string + * The Composer version. + * + * @throws \UnexpectedValueException + * Thrown if the Composer version cannot be determined. + */ + public function getVersion(): string { + $this->runner->run(['--format=json'], callback: $this->processCallback->reset()); + $data = $this->processCallback->parseJsonOutput(); + if (isset($data['application']['name']) + && isset($data['application']['version']) + && $data['application']['name'] === 'Composer' + && is_string($data['application']['version'])) { + return $data['application']['version']; + } + throw new \UnexpectedValueException('Unable to determine Composer version'); + } + + /** + * Returns the installed packages list. + * + * @param string $working_dir + * The working directory in which to run Composer. Should contain a + * `composer.lock` file. + * + * @return \Drupal\package_manager\InstalledPackagesList + * The installed packages list for the directory. + * + * @throws \UnexpectedValueException + * Thrown if a package reports that its install path is the same as the + * working directory, and it is not of the `metapackage` type. + */ + public function getInstalledPackagesList(string $working_dir): InstalledPackagesList { + $working_dir = realpath($working_dir); + $this->validate($working_dir); + + if (array_key_exists($working_dir, $this->packageLists)) { + return $this->packageLists[$working_dir]; + } + + $packages_data = $this->show($working_dir); + $packages_data = $this->getPackageTypes($packages_data, $working_dir); + + foreach ($packages_data as $name => $package) { + $path = $package['path']; + + // For packages installed as dev snapshots from certain version control + // systems, `composer show` displays the version like `1.0.x-dev 0a1b2c`, + // which will cause an exception if we try to parse it as a legitimate + // semantic version. Since we don't need the abbreviated commit hash, just + // remove it. + if (str_contains($package['version'], '-dev ')) { + $packages_data[$name]['version'] = explode(' ', $package['version'], 2)[0]; + } + + // We expect Composer to report that metapackages' install paths are the + // same as the working directory, in which case InstalledPackage::$path + // should be NULL. For all other package types, we consider it invalid + // if the install path is the same as the working directory. + if (isset($package['type']) && $package['type'] === 'metapackage') { + if ($path !== NULL) { + throw new \UnexpectedValueException("Metapackage '$name' is installed at unexpected path: '$path', expected NULL"); + } + $packages_data[$name]['path'] = $path; + } + elseif ($path === $working_dir) { + throw new \UnexpectedValueException("Package '$name' cannot be installed at path: '$path'"); + } + else { + $packages_data[$name]['path'] = realpath($path); + } + } + $packages_data = array_map(InstalledPackage::createFromArray(...), $packages_data); + + $list = new InstalledPackagesList($packages_data); + $this->packageLists[$working_dir] = $list; + + return $list; + } + + /** + * Loads package types from the lock file. + * + * The package type is not available using `composer show` for listing + * packages. To avoiding making many calls to `composer show package-name`, + * load the lock file data to get the `type` key. + * + * @param array $packages_data + * The packages data returned from ::show(). + * @param string $working_dir + * The directory where Composer was run. + * + * @return array + * The packages data, with a `type` key added to each package. + */ + private function getPackageTypes(array $packages_data, string $working_dir): array { + $lock_content = file_get_contents($working_dir . DIRECTORY_SEPARATOR . 'composer.lock'); + $lock_data = json_decode($lock_content, TRUE, flags: JSON_THROW_ON_ERROR); + + $lock_packages = array_merge($lock_data['packages'] ?? [], $lock_data['packages-dev'] ?? []); + foreach ($lock_packages as $lock_package) { + $name = $lock_package['name']; + if (isset($packages_data[$name]) && isset($lock_package['type'])) { + $packages_data[$name]['type'] = $lock_package['type']; + } + } + return $packages_data; + } + + /** + * Returns the output of `composer show --self` in a directory. + * + * @param string $working_dir + * The directory in which to run Composer. + * + * @return array + * The parsed output of `composer show --self`. + */ + public function getRootPackageInfo(string $working_dir): array { + $this->validate($working_dir); + + $this->runner->run(['show', '--self', '--format=json', "--working-dir={$working_dir}"], callback: $this->processCallback->reset()); + return $this->processCallback->parseJsonOutput(); + } + + /** + * Gets the installed packages data from running `composer show`. + * + * @param string $working_dir + * The directory in which to run `composer show`. + * + * @return array[] + * The installed packages data, keyed by package name. + */ + protected function show(string $working_dir): array { + $data = []; + $options = ['show', '--format=json', "--working-dir={$working_dir}"]; + + // We don't get package installation paths back from `composer show` unless + // we explicitly pass the --path option to it. However, for some + // inexplicable reason, that option hides *other* relevant information + // about the installed packages. So, to work around this maddening quirk, we + // call `composer show` once without the --path option, and once with it, + // then merge the results together. Composer, for its part, will not support + // returning the install path from `composer show`: see + // https://github.com/composer/composer/pull/11340. + $this->runner->run($options, callback: $this->processCallback->reset()); + $output = $this->processCallback->parseJsonOutput(); + // $output['installed'] will not be set if no packages are installed. + if (isset($output['installed'])) { + foreach ($output['installed'] as $installed_package) { + $data[$installed_package['name']] = $installed_package; + } + + $options[] = '--path'; + $this->runner->run($options, callback: $this->processCallback->reset()); + $output = $this->processCallback->parseJsonOutput(); + foreach ($output['installed'] as $installed_package) { + $data[$installed_package['name']]['path'] = $installed_package['path']; + } + } + + return $data; + } + + /** + * Invalidates cached data if composer.json or composer.lock have changed. + * + * The following cached data may be invalidated: + * - Installed package lists (see ::getInstalledPackageList()). + * + * @param string $working_dir + * A directory that contains a `composer.json` file, and optionally a + * `composer.lock`. If either file has changed since the last time this + * method was called, any cached data for the directory will be invalidated. + * + * @return bool + * TRUE if the cached data was invalidated, otherwise FALSE. + */ + private function invalidateCacheIfNeeded(string $working_dir): bool { + static $known_hashes = []; + + $invalidate = FALSE; + foreach (['composer.json', 'composer.lock'] as $filename) { + $known_hash = $known_hashes[$working_dir][$filename] ?? ''; + // If the file doesn't exist, hash_file() will return FALSE. + $current_hash = @hash_file('xxh64', $working_dir . DIRECTORY_SEPARATOR . $filename); + + if ($known_hash && $current_hash && hash_equals($known_hash, $current_hash)) { + continue; + } + $known_hashes[$working_dir][$filename] = $current_hash; + $invalidate = TRUE; + } + if ($invalidate) { + unset($this->packageLists[$working_dir]); + } + return $invalidate; + } + + /** + * Returns the value of `allow-plugins` config setting. + * + * @param string $dir + * The directory in which to run Composer. + * + * @return bool[]|bool + * An array of boolean flags to allow or disallow certain plugins, or TRUE + * if all plugins are allowed. + * + * @see https://getcomposer.org/doc/06-config.md#allow-plugins + */ + public function getAllowPluginsConfig(string $dir): array|bool { + $value = $this->getConfig('allow-plugins', $dir); + + // Try to convert the value we got back to a boolean. If it's not a boolean, + // it should be an array of plugin-specific flags. + $value = json_decode($value, TRUE, flags: JSON_THROW_ON_ERROR); + + // An empty array indicates that no plugins are allowed. + return $value ?: []; + } + +} diff --git a/core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php b/core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php new file mode 100644 index 000000000000..a3619d233bed --- /dev/null +++ b/core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php @@ -0,0 +1,128 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\StageBase; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathListFactoryInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; + +/** + * Defines an event that collects paths to exclude. + * + * These paths are excluded by Composer Stager and are never copied into the + * stage directory from the active directory, or vice versa. + */ +final class CollectPathsToExcludeEvent extends StageEvent implements PathListInterface { + + /** + * Constructs a CollectPathsToExcludeEvent object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage which fired this event. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface|null $pathList + * (optional) The list of paths to exclude. + */ + public function __construct( + StageBase $stage, + private readonly PathLocator $pathLocator, + private readonly PathFactoryInterface $pathFactory, + private ?PathListInterface $pathList = NULL, + ) { + parent::__construct($stage); + + $this->pathList ??= \Drupal::service(PathListFactoryInterface::class) + ->create(); + } + + /** + * {@inheritdoc} + */ + public function add(string ...$paths): void { + $this->pathList->add(...$paths); + } + + /** + * {@inheritdoc} + */ + public function getAll(): array { + return array_unique($this->pathList->getAll()); + } + + /** + * Flags paths to be ignored, relative to the web root. + * + * This should only be used for paths that, if they exist at all, are + * *guaranteed* to exist within the web root. + * + * @param string[] $paths + * The paths to ignore. These should be relative to the web root. They will + * be made relative to the project root. + */ + public function addPathsRelativeToWebRoot(array $paths): void { + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $web_root .= '/'; + } + + foreach ($paths as $path) { + // Make the path relative to the project root by prefixing the web root. + $this->add($web_root . $path); + } + } + + /** + * Flags paths to be ignored, relative to the project root. + * + * @param string[] $paths + * The paths to ignore. Absolute paths will be made relative to the project + * root; relative paths are assumed to be relative to the project root. + * + * @throws \LogicException + * If any of the given paths are absolute, but not inside the project root. + */ + public function addPathsRelativeToProjectRoot(array $paths): void { + $project_root = $this->pathLocator->getProjectRoot(); + + foreach ($paths as $path) { + if ($this->pathFactory->create($path)->isAbsolute()) { + if (!str_starts_with($path, $project_root)) { + throw new \LogicException("$path is not inside the project root: $project_root."); + } + } + + // Make absolute paths relative to the project root. + $path = str_replace($project_root, '', $path); + $path = ltrim($path, '/'); + $this->add($path); + } + } + + /** + * Finds all directories in the project root matching the given name. + * + * @param string $directory_name + * A directory name. + * + * @return string[] + * All discovered absolute paths matching the given directory name. + */ + public function scanForDirectoriesByName(string $directory_name): array { + $flags = \FilesystemIterator::UNIX_PATHS; + $flags |= \FilesystemIterator::CURRENT_AS_SELF; + $directories_tree = new \RecursiveDirectoryIterator($this->pathLocator->getProjectRoot(), $flags); + $filtered_directories = new \RecursiveIteratorIterator($directories_tree, \RecursiveIteratorIterator::SELF_FIRST); + $matched_directories = new \CallbackFilterIterator($filtered_directories, + fn (\RecursiveDirectoryIterator $current) => $current->isDir() && $current->getFilename() === $directory_name + ); + return array_keys(iterator_to_array($matched_directories)); + } + +} diff --git a/core/modules/package_manager/src/Event/PostApplyEvent.php b/core/modules/package_manager/src/Event/PostApplyEvent.php new file mode 100644 index 000000000000..e31a9fd0de8d --- /dev/null +++ b/core/modules/package_manager/src/Event/PostApplyEvent.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +/** + * Event fired after staged changes are synced to the active directory. + */ +final class PostApplyEvent extends StageEvent { +} diff --git a/core/modules/package_manager/src/Event/PostCreateEvent.php b/core/modules/package_manager/src/Event/PostCreateEvent.php new file mode 100644 index 000000000000..6278aa150eaa --- /dev/null +++ b/core/modules/package_manager/src/Event/PostCreateEvent.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +/** + * Event fired after a stage directory has been created. + */ +final class PostCreateEvent extends StageEvent { +} diff --git a/core/modules/package_manager/src/Event/PostRequireEvent.php b/core/modules/package_manager/src/Event/PostRequireEvent.php new file mode 100644 index 000000000000..47847b69b61e --- /dev/null +++ b/core/modules/package_manager/src/Event/PostRequireEvent.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +/** + * Event fired after packages are updated to the stage directory. + */ +final class PostRequireEvent extends StageEvent { + + use RequireEventTrait; + +} diff --git a/core/modules/package_manager/src/Event/PreApplyEvent.php b/core/modules/package_manager/src/Event/PreApplyEvent.php new file mode 100644 index 000000000000..21af791a7ffd --- /dev/null +++ b/core/modules/package_manager/src/Event/PreApplyEvent.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\ImmutablePathList; +use Drupal\package_manager\StageBase; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; + +/** + * Event fired before staged changes are synced to the active directory. + */ +final class PreApplyEvent extends PreOperationStageEvent { + + /** + * The list of paths to ignore in the active and stage directories. + * + * @var \Drupal\package_manager\ImmutablePathList + */ + public readonly ImmutablePathList $excludedPaths; + + /** + * Constructs a PreApplyEvent object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage which fired this event. + * @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface $excluded_paths + * The list of paths to exclude. These will not be copied from the stage + * directory to the active directory, nor be deleted from the active + * directory if they exist, when the stage directory is copied back into + * the active directory. + */ + public function __construct(StageBase $stage, PathListInterface $excluded_paths) { + parent::__construct($stage); + $this->excludedPaths = new ImmutablePathList($excluded_paths); + } + +} diff --git a/core/modules/package_manager/src/Event/PreCreateEvent.php b/core/modules/package_manager/src/Event/PreCreateEvent.php new file mode 100644 index 000000000000..59dd01a757fc --- /dev/null +++ b/core/modules/package_manager/src/Event/PreCreateEvent.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\ImmutablePathList; +use Drupal\package_manager\StageBase; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; + +/** + * Event fired before a stage directory is created. + */ +final class PreCreateEvent extends PreOperationStageEvent { + + /** + * The list of paths to exclude from the stage directory. + * + * @var \Drupal\package_manager\ImmutablePathList + */ + public readonly ImmutablePathList $excludedPaths; + + /** + * Constructs a PreCreateEvent object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage which fired this event. + * @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface $excluded_paths + * The list of paths to exclude. These will not be copied into the stage + * directory when it is created. + */ + public function __construct(StageBase $stage, PathListInterface $excluded_paths) { + parent::__construct($stage); + $this->excludedPaths = new ImmutablePathList($excluded_paths); + } + +} diff --git a/core/modules/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/package_manager/src/Event/PreOperationStageEvent.php new file mode 100644 index 000000000000..2e6eb0c52927 --- /dev/null +++ b/core/modules/package_manager/src/Event/PreOperationStageEvent.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; + +/** + * Base class for events dispatched before a stage life cycle operation. + */ +abstract class PreOperationStageEvent extends StageEvent { + + /** + * The validation results. + * + * @var \Drupal\package_manager\ValidationResult[] + */ + protected $results = []; + + /** + * Gets the validation results. + * + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return \Drupal\package_manager\ValidationResult[] + * The validation results. + */ + public function getResults(?int $severity = NULL): array { + if ($severity !== NULL) { + return array_filter($this->results, function ($result) use ($severity) { + return $result->severity === $severity; + }); + } + return $this->results; + } + + /** + * Convenience method to flag a validation error. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The summary of error messages. Must be passed if there is more than one + * message. + */ + public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void { + $this->addResult(ValidationResult::createError(array_values($messages), $summary)); + } + + /** + * Convenience method, adds an error validation result from a throwable. + * + * @param \Throwable $throwable + * The throwable. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * (optional) The summary of error messages. + */ + public function addErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): void { + $this->addResult(ValidationResult::createErrorFromThrowable($throwable, $summary)); + } + + /** + * Adds a validation result to the event. + * + * @param \Drupal\package_manager\ValidationResult $result + * The validation result to add. + * + * @throws \InvalidArgumentException + * Thrown if the validation result is not an error. + */ + public function addResult(ValidationResult $result): void { + // Only errors are allowed for this event. + if ($result->severity !== SystemManager::REQUIREMENT_ERROR) { + throw new \InvalidArgumentException('Only errors are allowed.'); + } + $this->results[] = $result; + } + + /** + * {@inheritdoc} + */ + public function stopPropagation(): void { + if (empty($this->getResults(SystemManager::REQUIREMENT_ERROR))) { + $this->addErrorFromThrowable(new \LogicException('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.')); + } + parent::stopPropagation(); + } + +} diff --git a/core/modules/package_manager/src/Event/PreRequireEvent.php b/core/modules/package_manager/src/Event/PreRequireEvent.php new file mode 100644 index 000000000000..54b697b17a52 --- /dev/null +++ b/core/modules/package_manager/src/Event/PreRequireEvent.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +/** + * Event fired before packages are updated to the stage directory. + */ +final class PreRequireEvent extends PreOperationStageEvent { + + use RequireEventTrait; + +} diff --git a/core/modules/package_manager/src/Event/RequireEventTrait.php b/core/modules/package_manager/src/Event/RequireEventTrait.php new file mode 100644 index 000000000000..deec0a172592 --- /dev/null +++ b/core/modules/package_manager/src/Event/RequireEventTrait.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\StageBase; + +/** + * Common methods for pre- and post-require events. + * + * @internal + * This is an internal part of Automatic Updates and should only be used by + * \Drupal\package_manager\Event\PreRequireEvent and + * \Drupal\package_manager\Event\PostRequireEvent. + */ +trait RequireEventTrait { + + /** + * The runtime packages, in the form 'vendor/name:constraint'. + * + * @var string[] + */ + private $runtimePackages; + + /** + * The dev packages to be required, in the form 'vendor/name:constraint'. + * + * @var string[] + */ + private $devPackages; + + /** + * Constructs the object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage. + * @param string[] $runtime_packages + * The runtime (i.e., non-dev) packages to be required, in the form + * 'vendor/name:constraint'. + * @param string[] $dev_packages + * The dev packages to be required, in the form 'vendor/name:constraint'. + */ + public function __construct(StageBase $stage, array $runtime_packages, array $dev_packages = []) { + $this->runtimePackages = $runtime_packages; + $this->devPackages = $dev_packages; + parent::__construct($stage); + } + + /** + * Gets the runtime (i.e., non-dev) packages. + * + * @return string[] + * An array of packages where the keys are package names in the form + * `vendor/name` and the values are version constraints. Packages without a + * version constraint will default to `*`. + */ + public function getRuntimePackages(): array { + return $this->getKeyedPackages($this->runtimePackages); + } + + /** + * Gets the dev packages. + * + * @return string[] + * An array of packages where the values are version constraints and keys + * are package names in the form `vendor/name`. Packages without a version + * constraint will default to `*`. + */ + public function getDevPackages(): array { + return $this->getKeyedPackages($this->devPackages); + } + + /** + * Gets packages as a keyed array. + * + * @param string[] $packages + * The packages, in the form 'vendor/name:version'. + * + * @return string[] + * An array of packages where the values are version constraints and keys + * are package names in the form `vendor/name`. Packages without a version + * constraint will default to `*`. + */ + private function getKeyedPackages(array $packages): array { + $keyed_packages = []; + foreach ($packages as $package) { + if (strpos($package, ':') > 0) { + [$name, $constraint] = explode(':', $package); + } + else { + [$name, $constraint] = [$package, '*']; + } + $keyed_packages[$name] = $constraint; + } + return $keyed_packages; + } + +} diff --git a/core/modules/package_manager/src/Event/StageEvent.php b/core/modules/package_manager/src/Event/StageEvent.php new file mode 100644 index 000000000000..3f69f7cdf4f7 --- /dev/null +++ b/core/modules/package_manager/src/Event/StageEvent.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\StageBase; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Base class for all events related to the life cycle of the stage. + */ +abstract class StageEvent extends Event { + + /** + * Constructs a StageEvent object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage which fired this event. + */ + public function __construct(public readonly StageBase $stage) { + } + +} diff --git a/core/modules/package_manager/src/Event/StatusCheckEvent.php b/core/modules/package_manager/src/Event/StatusCheckEvent.php new file mode 100644 index 000000000000..4540751a74b0 --- /dev/null +++ b/core/modules/package_manager/src/Event/StatusCheckEvent.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Event; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\ImmutablePathList; +use Drupal\package_manager\StageBase; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; + +/** + * Event fired to check the status of the system to use Package Manager. + */ +final class StatusCheckEvent extends PreOperationStageEvent { + + /** + * The paths to exclude, or NULL if there was an error collecting them. + * + * @var \Drupal\package_manager\ImmutablePathList|null + * + * @see ::__construct() + */ + public readonly ?ImmutablePathList $excludedPaths; + + /** + * Constructs a StatusCheckEvent object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage which fired this event. + * @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface|\Throwable $excluded_paths + * The list of paths to exclude or, if an error occurred while they were + * being collected, the throwable from that error. If this is a throwable, + * it will be converted to a validation error. + */ + public function __construct(StageBase $stage, PathListInterface|\Throwable $excluded_paths) { + parent::__construct($stage); + + // If there was an error collecting the excluded paths, convert it to a + // validation error so we can still run status checks that don't need to + // examine the list of excluded paths. + if ($excluded_paths instanceof \Throwable) { + $this->addErrorFromThrowable($excluded_paths); + $excluded_paths = NULL; + } + else { + $excluded_paths = new ImmutablePathList($excluded_paths); + } + $this->excludedPaths = $excluded_paths; + } + + /** + * Adds warning information to the event. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * One or more warning messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * A summary of warning messages. Must be passed if there is more than one + * message. + */ + public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void { + $this->addResult(ValidationResult::createWarning($messages, $summary)); + } + + /** + * {@inheritdoc} + */ + public function addResult(ValidationResult $result): void { + // Override the parent to also allow warnings. + $this->results[] = $result; + } + +} diff --git a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php new file mode 100644 index 000000000000..9c2f2f588aeb --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php @@ -0,0 +1,184 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Event subscriber to log changes that happen during the stage life cycle. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + use StringTranslationTrait; + + /** + * The key to store the list of packages installed when the stage is created. + * + * @var string + * + * @see ::recordInstalledPackages() + */ + private const INSTALLED_PACKAGES_KEY = 'package_manager_installed_packages'; + + /** + * The metadata key under which to store the requested package versions. + * + * @var string + * + * @see ::recordRequestedPackageVersions() + */ + private const REQUESTED_PACKAGES_KEY = 'package_manager_requested_packages'; + + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * Records packages installed in the project root. + * + * We need to do this before the staging environment has been created, so that + * we have a complete picture of which requested packages are merely being + * updated, and which are being newly added. Once the staging environment has + * been created, the installed packages won't change -- if they do, a + * validation error will be raised. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event being handled. + * + * @see \Drupal\package_manager\Validator\LockFileValidator + */ + public function recordInstalledPackages(PreCreateEvent $event): void { + $packages = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $event->stage->setMetadata(static::INSTALLED_PACKAGES_KEY, $packages); + } + + /** + * Records requested packages. + * + * @param \Drupal\package_manager\Event\PostRequireEvent $event + * The event object. + */ + public function recordRequestedPackageVersions(PostRequireEvent $event): void { + // There could be multiple 'require' operations, so overlay the requested + // packages from the current operation onto the requested packages from any + // previous 'require' operation. + $requested_packages = array_merge( + $event->stage->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? [], + $event->getRuntimePackages(), + $event->getDevPackages(), + ); + $event->stage->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages); + } + + /** + * Logs changes made by Package Manager. + * + * @param \Drupal\package_manager\Event\PostApplyEvent $event + * The event being handled. + */ + public function logChanges(PostApplyEvent $event): void { + $installed_at_start = $event->stage->getMetadata(static::INSTALLED_PACKAGES_KEY); + $installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + + // Compare the packages which were installed when the stage was created + // against the package versions that were requested over all the stage's + // require operations, and create a log entry listing all of it. + $requested_log = []; + + $requested_packages = $event->stage->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? []; + // Sort the requested packages by name, to make it easier to review a large + // change list. + ksort($requested_packages, SORT_NATURAL); + foreach ($requested_packages as $name => $constraint) { + $installed_version = $installed_at_start[$name]?->version; + if ($installed_version === NULL) { + // For clarity, make the "any version" constraint human-readable. + if ($constraint === '*') { + $constraint = $this->t('* (any version)'); + } + $requested_log[] = $this->t('- Install @name @constraint', [ + '@name' => $name, + '@constraint' => $constraint, + ]); + } + else { + $requested_log[] = $this->t('- Update @name from @installed_version to @constraint', [ + '@name' => $name, + '@installed_version' => $installed_version, + '@constraint' => $constraint, + ]); + } + } + // It's possible that $requested_log will be empty: for example, a custom + // stage that only does removals, or some other operation, and never + // dispatches PostRequireEvent. + if ($requested_log) { + $message = $this->t("Requested changes:\n@change_list", [ + '@change_list' => implode("\n", array_map('strval', $requested_log)), + ]); + $this->logger?->info($message); + } + + // Create a separate log entry listing everything that actually changed. + $applied_log = []; + + $updated_packages = $installed_post_apply->getPackagesWithDifferentVersionsIn($installed_at_start); + // Sort the packages by name to make it easier to review large change sets. + $updated_packages->ksort(SORT_NATURAL); + foreach ($updated_packages as $name => $package) { + $applied_log[] = $this->t('- Updated @name from @installed_version to @updated_version', [ + '@name' => $name, + '@installed_version' => $installed_at_start[$name]->version, + '@updated_version' => $package->version, + ]); + } + + $added_packages = $installed_post_apply->getPackagesNotIn($installed_at_start); + $added_packages->ksort(SORT_NATURAL); + foreach ($added_packages as $name => $package) { + $applied_log[] = $this->t('- Installed @name @version', [ + '@name' => $name, + '@version' => $package->version, + ]); + } + + $removed_packages = $installed_at_start->getPackagesNotIn($installed_post_apply); + $removed_packages->ksort(SORT_NATURAL); + foreach ($installed_at_start->getPackagesNotIn($installed_post_apply) as $name => $package) { + $applied_log[] = $this->t('- Uninstalled @name', ['@name' => $name]); + } + $message = $this->t("Applied changes:\n@change_list", [ + '@change_list' => implode("\n", array_map('strval', $applied_log)), + ]); + $this->logger?->info($message); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => ['recordInstalledPackages'], + PostRequireEvent::class => ['recordRequestedPackageVersions'], + PostApplyEvent::class => ['logChanges'], + ]; + } + +} diff --git a/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php b/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php new file mode 100644 index 000000000000..1bcc900b6910 --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\update\UpdateManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Clears stale update data once staged changes have been applied. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class UpdateDataSubscriber implements EventSubscriberInterface { + + public function __construct(private readonly UpdateManagerInterface $updateManager) { + } + + /** + * Clears stale update data. + * + * This will always run after any stage directory changes are applied to the + * active directory, since it's likely that core and/or multiple extensions + * have been added, removed, or updated. + */ + public function clearData(): void { + $this->updateManager->refreshUpdateData(); + update_storage_clear(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PostApplyEvent::class => ['clearData', 1000], + ]; + } + +} diff --git a/core/modules/package_manager/src/Exception/ApplyFailedException.php b/core/modules/package_manager/src/Exception/ApplyFailedException.php new file mode 100644 index 000000000000..59643170f765 --- /dev/null +++ b/core/modules/package_manager/src/Exception/ApplyFailedException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if a stage encounters an error applying an update. + * + * If this exception is thrown it indicates that an update of the active + * codebase was attempted but failed. If this happens the site code is in an + * indeterminate state. Package Manager does not provide a method for recovering + * from this state. The site code should be restored from a backup. + * + * This exception is different from StageFailureMarkerException in that it is + * thrown if an error happens *during* the apply operation, rather than before + * or after it. + * + * Should not be thrown by external code. + */ +final class ApplyFailedException extends StageException { +} diff --git a/core/modules/package_manager/src/Exception/ComposerNotReadyException.php b/core/modules/package_manager/src/Exception/ComposerNotReadyException.php new file mode 100644 index 000000000000..e0842ef6959b --- /dev/null +++ b/core/modules/package_manager/src/Exception/ComposerNotReadyException.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if we cannot reliably use Composer. + * + * Should not be thrown by external code. + * + * @see \Drupal\package_manager\ComposerInspector::validate() + */ +final class ComposerNotReadyException extends \RuntimeException { + + /** + * Constructs a ComposerNotReadyException object. + * + * @param string|null $workingDir + * The directory where Composer was run, or NULL if the errors are related + * to the Composer executable itself. + * @param array $messages + * An array of messages explaining why Composer cannot be run correctly. + * @param int $code + * (optional) The exception code. Defaults to 0. + * @param \Throwable|null $previous + * (optional) The previous exception, for exception chaining. + */ + public function __construct(public readonly ?string $workingDir, array $messages, int $code = 0, ?\Throwable $previous = NULL) { + parent::__construct(implode("\n", $messages), $code, $previous); + } + +} diff --git a/core/modules/package_manager/src/Exception/StageEventException.php b/core/modules/package_manager/src/Exception/StageEventException.php new file mode 100644 index 000000000000..128fe39f4ef6 --- /dev/null +++ b/core/modules/package_manager/src/Exception/StageEventException.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StageEvent; + +/** + * Exception thrown if an error related to an event occurs. + * + * This exception is thrown when an error strictly associated with an event + * occurs. This is also what makes it different from StageException. + * + * Should not be thrown by external code. + */ +class StageEventException extends StageException { + + /** + * Constructs a StageEventException object. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The stage event during which this exception is thrown. + * @param string|null $message + * (optional) The exception message. Defaults to a plain text representation + * of the validation results. + * @param mixed ...$arguments + * Additional arguments to pass to the parent constructor. + */ + public function __construct(public readonly StageEvent $event, ?string $message = NULL, ...$arguments) { + parent::__construct($event->stage, $message ?: $this->getResultsAsText(), ...$arguments); + } + + /** + * Formats the validation results, if any, as plain text. + * + * @return string + * The results, formatted as plain text. + */ + protected function getResultsAsText(): string { + $text = ''; + if ($this->event instanceof PreOperationStageEvent) { + foreach ($this->event->getResults() as $result) { + $messages = $result->messages; + $summary = $result->summary; + if ($summary) { + array_unshift($messages, $summary); + } + $text .= implode("\n", $messages) . "\n"; + } + } + return $text; + } + +} diff --git a/core/modules/package_manager/src/Exception/StageException.php b/core/modules/package_manager/src/Exception/StageException.php new file mode 100644 index 000000000000..499c3962c374 --- /dev/null +++ b/core/modules/package_manager/src/Exception/StageException.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +use Drupal\package_manager\StageBase; + +/** + * Base class for all exceptions related to stage operations. + * + * Should not be thrown by external code. + */ +class StageException extends \RuntimeException { + + /** + * Constructs a StageException object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage. + * @param mixed ...$arguments + * Additional arguments to pass to the parent constructor. + */ + public function __construct(public readonly StageBase $stage, ...$arguments) { + parent::__construct(...$arguments); + } + +} diff --git a/core/modules/package_manager/src/Exception/StageFailureMarkerException.php b/core/modules/package_manager/src/Exception/StageFailureMarkerException.php new file mode 100644 index 000000000000..21fc6d4bb864 --- /dev/null +++ b/core/modules/package_manager/src/Exception/StageFailureMarkerException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if a stage can't be created due to an earlier failed commit. + * + * If this exception is thrown it indicates that an earlier commit operation had + * failed. If this happens the site code is in an indeterminate state. Package + * Manager does not provide a method for recovering from this state. The site + * code should be restored from a backup. + * + * We are extending RuntimeException rather than StageException which makes it + * clear that it's unrelated to the stage life cycle. + * + * This exception is different from ApplyFailedException as it focuses on + * the failure marker being detected outside the stage lifecycle. + */ +final class StageFailureMarkerException extends \RuntimeException { +} diff --git a/core/modules/package_manager/src/Exception/StageOwnershipException.php b/core/modules/package_manager/src/Exception/StageOwnershipException.php new file mode 100644 index 000000000000..aa6f3ec2ef87 --- /dev/null +++ b/core/modules/package_manager/src/Exception/StageOwnershipException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if a stage encounters an ownership or locking error. + * + * Should not be thrown by external code. + */ +final class StageOwnershipException extends StageException { +} diff --git a/core/modules/package_manager/src/ExecutableFinder.php b/core/modules/package_manager/src/ExecutableFinder.php new file mode 100644 index 000000000000..d8a2f1c21d52 --- /dev/null +++ b/core/modules/package_manager/src/ExecutableFinder.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * An executable finder which looks for executable paths in configuration. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ExecutableFinder implements ExecutableFinderInterface { + + public function __construct( + private readonly ExecutableFinderInterface $decorated, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public function find(string $name): string { + $executables = $this->configFactory->get('package_manager.settings') + ->get('executables'); + + return $executables[$name] ?? $this->decorated->find($name); + } + +} diff --git a/core/modules/package_manager/src/FailureMarker.php b/core/modules/package_manager/src/FailureMarker.php new file mode 100644 index 000000000000..98181ae52094 --- /dev/null +++ b/core/modules/package_manager/src/FailureMarker.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\Exception\StageFailureMarkerException; + +/** + * Handles failure marker file operation. + * + * The failure marker is a file placed in the active directory while staged + * code is copied back into it, and then removed afterward. This allows us to + * know if a commit operation failed midway through, which could leave the site + * code base in an indeterminate state -- which, in the worst case scenario, + * might render Drupal being unable to boot. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class FailureMarker implements EventSubscriberInterface { + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Gets the marker file path. + * + * @return string + * The absolute path of the marker file. + */ + public function getPath(): string { + return $this->pathLocator->getProjectRoot() . '/PACKAGE_MANAGER_FAILURE.yml'; + } + + /** + * Deletes the marker file. + */ + public function clear(): void { + unlink($this->getPath()); + } + + /** + * Writes data to marker file. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup $message + * Failure message to be added. + * @param \Throwable|null $throwable + * (optional) The throwable that caused the failure. + */ + public function write(StageBase $stage, TranslatableMarkup $message, ?\Throwable $throwable = NULL): void { + $data = [ + 'stage_class' => get_class($stage), + 'stage_type' => $stage->getType(), + 'stage_file' => (new \ReflectionObject($stage))->getFileName(), + 'message' => (string) $message, + 'throwable_class' => $throwable ? get_class($throwable) : FALSE, + 'throwable_message' => $throwable?->getMessage() ?? 'Not available', + 'throwable_backtrace' => $throwable?->getTraceAsString() ?? 'Not available.', + ]; + file_put_contents($this->getPath(), Yaml::dump($data)); + } + + /** + * Gets the data from the file if it exists. + * + * @return array|null + * The data from the file if it exists. + * + * @throws \Drupal\package_manager\Exception\StageFailureMarkerException + * Thrown if failure marker exists but cannot be decoded. + */ + private function getData(): ?array { + $path = $this->getPath(); + if (file_exists($path)) { + $data = file_get_contents($path); + try { + return Yaml::parse($data); + + } + catch (ParseException $exception) { + throw new StageFailureMarkerException('Failure marker file exists but cannot be decoded.', $exception->getCode(), $exception); + } + } + return NULL; + } + + /** + * Gets the message from the file if it exists. + * + * @param bool $include_backtrace + * Whether to include the backtrace in the message. Defaults to TRUE. May be + * set to FALSE in a context where it does not make sense to include, such + * as emails. + * + * @return string|null + * The message from the file if it exists, otherwise NULL. + * + * @throws \Drupal\package_manager\Exception\StageFailureMarkerException + * Thrown if failure marker exists but cannot be decoded. + */ + public function getMessage(bool $include_backtrace = TRUE): ?string { + $data = $this->getData(); + if ($data === NULL) { + return NULL; + } + $message = $data['message']; + if ($data['throwable_class']) { + $message .= sprintf( + ' Caused by %s, with this message: %s', + $data['throwable_class'], + $data['throwable_message'], + ); + if ($include_backtrace) { + $message .= "\nBacktrace:\n" . $data['throwable_backtrace']; + } + } + return $message; + } + + /** + * Asserts the failure file doesn't exist. + * + * @throws \Drupal\package_manager\Exception\StageFailureMarkerException + * Thrown if the marker file exists. + */ + public function assertNotExists(): void { + if ($message = $this->getMessage()) { + throw new StageFailureMarkerException($message); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeMarkerFile', + ]; + } + + /** + * Excludes the failure marker file from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event being handled. + */ + public function excludeMarkerFile(CollectPathsToExcludeEvent $event): void { + $event->addPathsRelativeToProjectRoot([ + $this->getPath(), + ]); + } + +} diff --git a/core/modules/package_manager/src/FileProcessOutputCallback.php b/core/modules/package_manager/src/FileProcessOutputCallback.php new file mode 100644 index 000000000000..f236cf349de1 --- /dev/null +++ b/core/modules/package_manager/src/FileProcessOutputCallback.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * Logs process output to a file. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class FileProcessOutputCallback implements OutputCallbackInterface { + + /** + * The file to write to. + * + * @var resource + */ + private readonly mixed $handle; + + public function __construct( + string $path, + private readonly ?OutputCallbackInterface $decorated = NULL, + ) { + $this->handle = fopen($path, 'a'); + if (empty($this->handle)) { + throw new \RuntimeException("Could not open or create '$path' for writing."); + } + } + + /** + * {@inheritdoc} + */ + public function clearErrorOutput(): void { + $this->decorated?->clearErrorOutput(); + } + + /** + * {@inheritdoc} + */ + public function clearOutput(): void { + $this->decorated?->clearOutput(); + } + + /** + * {@inheritdoc} + */ + public function getErrorOutput(): array { + return $this->decorated?->getErrorOutput() ?? []; + } + + /** + * {@inheritdoc} + */ + public function getOutput(): array { + return $this->decorated?->getOutput() ?? []; + } + + /** + * {@inheritdoc} + */ + public function __invoke(OutputTypeEnum $type, string $buffer): void { + fwrite($this->handle, $buffer); + + if ($this->decorated) { + ($this->decorated)($type, $buffer); + } + } + +} diff --git a/core/modules/package_manager/src/ImmutablePathList.php b/core/modules/package_manager/src/ImmutablePathList.php new file mode 100644 index 000000000000..bed7c1c95d16 --- /dev/null +++ b/core/modules/package_manager/src/ImmutablePathList.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; + +/** + * Defines a path list that cannot be changed. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ImmutablePathList implements PathListInterface { + + public function __construct(private readonly PathListInterface $decorated) {} + + /** + * {@inheritdoc} + */ + public function add(string ...$paths): never { + throw new \LogicException('Immutable path lists cannot be changed.'); + } + + /** + * {@inheritdoc} + */ + public function getAll(): array { + return $this->decorated->getAll(); + } + +} diff --git a/core/modules/package_manager/src/InstalledPackage.php b/core/modules/package_manager/src/InstalledPackage.php new file mode 100644 index 000000000000..97a428d83fd9 --- /dev/null +++ b/core/modules/package_manager/src/InstalledPackage.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Serialization\Yaml; + +/** + * A value object that represents an installed Composer package. + */ +final class InstalledPackage { + + /** + * Constructs an InstalledPackage object. + * + * @param string $name + * The package name. + * @param string $version + * The package version. + * @param string|null $path + * The package path, or NULL if the package type is `metapackage`. + * @param string $type + * The package type. + */ + private function __construct( + public readonly string $name, + public readonly string $version, + public readonly ?string $path, + public readonly string $type, + ) {} + + /** + * Create an installed package object from an array. + * + * @param array $data + * The package data. + * + * @return static + */ + public static function createFromArray(array $data): static { + $path = isset($data['path']) ? realpath($data['path']) : NULL; + // Fall back to `library`. + // @see https://getcomposer.org/doc/04-schema.md#type + $type = $data['type'] ?? 'library'; + assert(($type === 'metapackage') === is_null($path), 'Metapackage install path must be NULL.'); + + return new static($data['name'], $data['version'], $path, $type); + } + + /** + * Returns the Drupal project name for this package. + * + * This assumes that drupal.org adds a `project` key to every `.info.yml` file + * in the package, regardless of where they are in the package's directory + * structure. The package name is irrelevant except for checking that the + * vendor is `drupal`. For example, if the project key in the info file were + * `my_module`, and the package name were `drupal/whatever`, and this method + * would return `my_module`. + * + * @return string|null + * The name of the Drupal project installed by this package, or NULL if: + * - The package type is not one of `drupal-module`, `drupal-theme`, or + * `drupal-profile`. + * - The package's vendor is not `drupal`. + * - The project name could not otherwise be determined. + * + * @throws \UnexpectedValueException + * Thrown if the same project name exists in more than one package. + */ + public function getProjectName(): ?string { + // Only consider packages which are packaged by drupal.org and will be + // known to the core Update module. + $drupal_package_types = [ + 'drupal-module', + 'drupal-theme', + 'drupal-profile', + ]; + if ($this->path && str_starts_with($this->name, 'drupal/') && in_array($this->type, $drupal_package_types, TRUE)) { + return $this->scanForProjectName(); + } + return NULL; + } + + /** + * Scans a given path to determine the Drupal project name. + * + * The path will be scanned recursively for `.info.yml` files containing a + * `project` key. + * + * @return string|null + * The name of the project, as declared in the first found `.info.yml` which + * contains a `project` key, or NULL if none was found. + */ + private function scanForProjectName(): ?string { + $iterator = new \RecursiveDirectoryIterator($this->path); + $iterator = new \RecursiveIteratorIterator($iterator); + $iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RegexIterator::GET_MATCH); + + foreach ($iterator as $match) { + $info = file_get_contents($match[0]); + $info = Yaml::decode($info); + + if (!empty($info['project'])) { + return $info['project']; + } + } + return NULL; + } + +} diff --git a/core/modules/package_manager/src/InstalledPackagesList.php b/core/modules/package_manager/src/InstalledPackagesList.php new file mode 100644 index 000000000000..8a8ed468778c --- /dev/null +++ b/core/modules/package_manager/src/InstalledPackagesList.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\Semver\Comparator; + +/** + * Defines a class to list installed Composer packages. + * + * This only lists the packages that were installed at the time this object was + * instantiated. If packages are added or removed later on, a new package list + * must be created to reflect those changes. + * + * @see \Drupal\package_manager\ComposerInspector::getInstalledPackagesList() + */ +final class InstalledPackagesList extends \ArrayObject { + + /** + * {@inheritdoc} + */ + public function append(mixed $value): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetSet(mixed $key, mixed $value): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset(mixed $key): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetGet(mixed $key): ?InstalledPackage { + // Overridden to provide a clearer return type hint and compatibility with + // the null-safe operator. + if ($this->offsetExists($key)) { + return parent::offsetGet($key); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function exchangeArray(mixed $array): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * Returns the packages that are in this list, but not in another. + * + * @param self $other + * Another list of installed packages. + * + * @return static + * A list of packages which are in this list but not the other one, keyed by + * name. + */ + public function getPackagesNotIn(self $other): static { + $packages = array_diff_key($this->getArrayCopy(), $other->getArrayCopy()); + return new static($packages); + } + + /** + * Returns the packages which have a different version in another list. + * + * This compares this list with another one, and returns a list of packages + * which are present in both, but in different versions. + * + * @param self $other + * Another list of installed packages. + * + * @return static + * A list of packages which are present in both this list and the other one, + * but in different versions, keyed by name. + */ + public function getPackagesWithDifferentVersionsIn(self $other): static { + // Only compare packages that are both here and there. + $packages = array_intersect_key($this->getArrayCopy(), $other->getArrayCopy()); + + $packages = array_filter($packages, fn (InstalledPackage $p) => Comparator::notEqualTo($p->version, $other[$p->name]->version)); + return new static($packages); + } + + /** + * Returns the package for a given Drupal project name, if it is installed. + * + * Although it is common for the package name to match the project name (for + * example, a project name of `token` is likely part of the `drupal/token` + * package), it's not guaranteed. Therefore, in order to avoid inadvertently + * reading information about the wrong package, use this method to properly + * determine which package installs a particular Drupal project. + * + * @param string $project_name + * The name of a Drupal project. + * + * @return \Drupal\package_manager\InstalledPackage|null + * The Composer package which installs the project, or NULL if it could not + * be determined. + */ + public function getPackageByDrupalProjectName(string $project_name): ?InstalledPackage { + $matching_package = NULL; + foreach ($this as $package) { + if ($package->getProjectName() === $project_name) { + if ($matching_package) { + throw new \UnexpectedValueException(sprintf("Project '%s' was found in packages '%s' and '%s'.", $project_name, $matching_package->name, $package->name)); + } + $matching_package = $package; + } + } + return $matching_package; + } + + /** + * Returns the canonical names of the supported core packages. + * + * @return string[] + * The canonical list of supported core package names. + */ + private static function getCorePackageList(): array { + // This method returns the installed packages that are considered part of + // Drupal core. There's no way to tell by package type alone, since these + // packages have varying types, but are all part of Drupal core's + // repository. + return [ + 'drupal/core', + 'drupal/core-composer-scaffold', + 'drupal/core-dev', + 'drupal/core-dev-pinned', + 'drupal/core-project-message', + 'drupal/core-recommended', + 'drupal/core-vendor-hardening', + ]; + } + + /** + * Returns a list of installed core packages. + * + * Packages returned by ::getCorePackageList() are considered core packages. + * + * @param bool $include_dev + * (optional) Whether to include core packages intended for development. + * Defaults to TRUE. + * + * @return static + * A list of the installed core packages. + */ + public function getCorePackages(bool $include_dev = TRUE): static { + $core_packages = array_intersect_key( + $this->getArrayCopy(), + array_flip(static::getCorePackageList()) + ); + + // If drupal/core-recommended is present, it supersedes drupal/core, since + // drupal/core will always be one of its direct dependencies. + if (array_key_exists('drupal/core-recommended', $core_packages)) { + unset($core_packages['drupal/core']); + } + if (!$include_dev) { + unset($core_packages['drupal/core-dev']); + unset($core_packages['drupal/core-dev-pinned']); + } + return new static($core_packages); + } + +} diff --git a/core/modules/package_manager/src/LegacyVersionUtility.php b/core/modules/package_manager/src/LegacyVersionUtility.php new file mode 100644 index 000000000000..64f46eee5f55 --- /dev/null +++ b/core/modules/package_manager/src/LegacyVersionUtility.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\Extension\ExtensionVersion; + +/** + * A utility class for dealing with legacy version numbers. + * + * @internal + * This is an internal utility class that could be changed or removed in any + * release and should not be used by external code. + */ +final class LegacyVersionUtility { + + /** + * Converts a version number to a semantic version if needed. + * + * @param string $version + * The version number. + * + * @return string + * The version number, converted if needed. + */ + public static function convertToSemanticVersion(string $version): string { + if (self::isLegacyVersion($version)) { + $version = substr($version, 4); + $version_parts = explode('-', $version); + $version = $version_parts[0] . '.0'; + if (count($version_parts) === 2) { + $version .= '-' . $version_parts[1]; + } + } + return $version; + } + + /** + * Converts a version number to a legacy version if needed and possible. + * + * @param string $version_string + * The version number. + * + * @return string|null + * The version number, converted if needed, or NULL if not possible. Only + * semantic version numbers that have patch level of 0 can be converted into + * legacy version numbers. + */ + public static function convertToLegacyVersion($version_string): ?string { + if (self::isLegacyVersion($version_string)) { + return $version_string; + } + $version = ExtensionVersion::createFromVersionString($version_string); + if ($extra = $version->getVersionExtra()) { + $version_string_without_extra = str_replace("-$extra", '', $version_string); + } + else { + $version_string_without_extra = $version_string; + } + [,, $patch] = explode('.', $version_string_without_extra); + // A semantic version can only be converted to legacy if it's patch level is + // '0'. + if ($patch !== '0') { + return NULL; + } + return '8.x-' . $version->getMajorVersion() . '.' . $version->getMinorVersion() . ($extra ? "-$extra" : ''); + } + + /** + * Determines if a version is legacy. + * + * @param string $version + * The version number. + * + * @return bool + * TRUE if the version is a legacy version number, otherwise FALSE. + */ + private static function isLegacyVersion(string $version): bool { + return stripos($version, '8.x-') === 0; + } + +} diff --git a/core/modules/package_manager/src/LoggingBeginner.php b/core/modules/package_manager/src/LoggingBeginner.php new file mode 100644 index 000000000000..806f253b924a --- /dev/null +++ b/core/modules/package_manager/src/LoggingBeginner.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * Logs Composer Stager's Beginner process output to a file. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class LoggingBeginner implements BeginnerInterface { + + public function __construct( + private readonly BeginnerInterface $decorated, + private readonly ConfigFactoryInterface $configFactory, + private readonly TimeInterface $time, + ) {} + + /** + * {@inheritdoc} + */ + public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $path = $this->configFactory->get('package_manager.settings')->get('log'); + if ($path) { + $callback = new FileProcessOutputCallback($path, $callback); + $callback(OutputTypeEnum::OUT, sprintf("### Beginning in %s\n", $stagingDir->absolute())); + } + + $start_time = $this->time->getCurrentMicroTime(); + $this->decorated->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout); + $end_time = $this->time->getCurrentMicroTime(); + if ($callback) { + $callback(OutputTypeEnum::OUT, sprintf("### Finished in %0.3f seconds\n", $end_time - $start_time)); + } + } + +} diff --git a/core/modules/package_manager/src/LoggingCommitter.php b/core/modules/package_manager/src/LoggingCommitter.php new file mode 100644 index 000000000000..919457146401 --- /dev/null +++ b/core/modules/package_manager/src/LoggingCommitter.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * Logs Composer Stager's Committer process output to a file. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class LoggingCommitter implements CommitterInterface { + + public function __construct( + private readonly CommitterInterface $decorated, + private readonly ConfigFactoryInterface $configFactory, + private readonly TimeInterface $time, + ) {} + + /** + * {@inheritdoc} + */ + public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $path = $this->configFactory->get('package_manager.settings')->get('log'); + if ($path) { + $callback = new FileProcessOutputCallback($path, $callback); + $callback(OutputTypeEnum::OUT, sprintf("### Committing changes from %s to %s\n", $stagingDir->absolute(), $activeDir->absolute())); + } + + $start_time = $this->time->getCurrentMicroTime(); + $this->decorated->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout); + $end_time = $this->time->getCurrentMicroTime(); + if ($callback) { + $callback(OutputTypeEnum::OUT, sprintf("### Finished in %0.3f seconds\n", $end_time - $start_time)); + } + } + +} diff --git a/core/modules/package_manager/src/LoggingStager.php b/core/modules/package_manager/src/LoggingStager.php new file mode 100644 index 000000000000..fd4c1c65140b --- /dev/null +++ b/core/modules/package_manager/src/LoggingStager.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * Logs Composer Stager's Stager process output to a file. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class LoggingStager implements StagerInterface { + + public function __construct( + private readonly StagerInterface $decorated, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public function stage(array $composerCommand, PathInterface $activeDir, PathInterface $stagingDir, ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $path = $this->configFactory->get('package_manager.settings')->get('log'); + if ($path) { + $callback = new FileProcessOutputCallback($path, $callback); + $callback(OutputTypeEnum::OUT, sprintf("### Staging '%s' in %s\n", implode(' ', $composerCommand), $stagingDir->absolute())); + } + $this->decorated->stage($composerCommand, $activeDir, $stagingDir, $callback, $timeout); + } + +} diff --git a/core/modules/package_manager/src/PackageManagerServiceProvider.php b/core/modules/package_manager/src/PackageManagerServiceProvider.php new file mode 100644 index 000000000000..648fc2a480b2 --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerServiceProvider.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\InstalledVersions; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderInterface; + +/** + * Defines dynamic container services for Package Manager. + * + * Scans the Composer Stager library and registers its classes in the Drupal + * service container. + * + * @todo Refactor this if/when https://www.drupal.org/i/3111008 is fixed. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PackageManagerServiceProvider implements ServiceProviderInterface { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + $path = InstalledVersions::getInstallPath('php-tuf/composer-stager') . '/src'; + + // Certain subdirectories of Composer Stager shouldn't be scanned for + // services. + $ignore_directories = [ + $path . '/API/Exception', + $path . '/Internal/Helper', + $path . '/Internal/Path/Value', + $path . '/Internal/Translation/Value', + ]; + // As we scan for services, compile a list of which classes implement which + // interfaces so that we can set up aliases for interfaces that are only + // implemented by one class (to facilitate autowiring). + $interfaces = []; + + // Find all `.php` files in Composer Stager which aren't in the ignored + // directories. + $iterator = new \RecursiveDirectoryIterator($path, \FilesystemIterator::CURRENT_AS_SELF | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new \RecursiveCallbackFilterIterator($iterator, static function (\SplFileInfo $current) use ($ignore_directories): bool { + if ($current->isDir()) { + return !in_array($current->getPathname(), $ignore_directories, TRUE); + } + return $current->getExtension() === 'php'; + }); + $iterator = new \RecursiveIteratorIterator($iterator); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + // Convert the file name to a class name. + $class_name = substr($file->getPathname(), strlen($path) + 1, -4); + $class_name = 'PhpTuf\\ComposerStager\\' . str_replace(DIRECTORY_SEPARATOR, '\\', $class_name); + + // Don't register interfaces and abstract classes as services. + $reflector = new \ReflectionClass($class_name); + if ($reflector->isInterface() || $reflector->isAbstract()) { + continue; + } + foreach ($reflector->getInterfaceNames() as $interface) { + $interfaces[$interface][] = $class_name; + } + // Register the class as an autowired, private service. + $container->register($class_name) + ->setClass($class_name) + ->setAutowired(TRUE) + ->setPublic(FALSE); + } + + // Create aliases for interfaces that are only implemented by one class. + // Ignore interfaces that already have a service alias. + foreach ($interfaces as $interface_name => $implementations) { + if (count($implementations) === 1 && !$container->hasAlias($interface_name)) { + $container->setAlias($interface_name, $implementations[0]); + } + } + + } + +} diff --git a/core/modules/package_manager/src/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/PackageManagerUninstallValidator.php new file mode 100644 index 000000000000..50dd001c0213 --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerUninstallValidator.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Extension\ModuleUninstallValidatorInterface; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Prevents any module from being uninstalled if update is in process. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly BeginnerInterface $beginner, + private readonly StagerInterface $stager, + private readonly CommitterInterface $committer, + private readonly QueueFactory $queueFactory, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly SharedTempStoreFactory $sharedTempStoreFactory, + private readonly TimeInterface $time, + private readonly PathFactoryInterface $pathFactory, + private readonly FailureMarker $failureMarker, + ) {} + + /** + * {@inheritdoc} + */ + public function validate($module) { + $stage = new class( + $this->pathLocator, + $this->beginner, + $this->stager, + $this->committer, + $this->queueFactory, + $this->eventDispatcher, + $this->sharedTempStoreFactory, + $this->time, + $this->pathFactory, + $this->failureMarker) extends StageBase {}; + $reasons = []; + if (!$stage->isAvailable() && $stage->isApplying()) { + $reasons[] = $this->t('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.'); + } + return $reasons; + } + +} diff --git a/core/modules/package_manager/src/PackageManagerUpdateProcessor.php b/core/modules/package_manager/src/PackageManagerUpdateProcessor.php new file mode 100644 index 000000000000..afb39e9f8cea --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerUpdateProcessor.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\Core\PrivateKey; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\State\StateInterface; +use Drupal\update\UpdateFetcherInterface; +use Drupal\update\UpdateProcessor; + +/** + * Extends the Update module's update processor allow fetching any project. + * + * The Update module's update processor service is intended to only fetch + * information for projects in the active codebase. Although it would be + * possible to use the Update module's update processor service to fetch + * information for projects not in the active code base this would add the + * project information to Update module's cache which would result in these + * projects being returned from the Update module's global functions such as + * update_get_available(). + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PackageManagerUpdateProcessor extends UpdateProcessor { + + public function __construct( + ConfigFactoryInterface $config_factory, + QueueFactory $queue_factory, + UpdateFetcherInterface $update_fetcher, + StateInterface $state_store, + PrivateKey $private_key, + KeyValueFactoryInterface $key_value_factory, + KeyValueExpirableFactoryInterface $key_value_expirable_factory, + TimeInterface $time, + ) { + parent::__construct(...func_get_args()); + $this->fetchQueue = $queue_factory->get('package_manager.update_fetch_tasks'); + $this->tempStore = $key_value_expirable_factory->get('package_manager.update'); + $this->fetchTaskStore = $key_value_factory->get('package_manager.update_fetch_task'); + $this->availableReleasesTempStore = $key_value_expirable_factory->get('package_manager.update_available_releases'); + } + + /** + * Gets the project data by name. + * + * @param string $name + * The project name. + * + * @return mixed[] + * The project data if any is available, otherwise NULL. + */ + public function getProjectData(string $name): ?array { + if ($this->availableReleasesTempStore->has($name)) { + return $this->availableReleasesTempStore->get($name); + } + $project_fetch_data = [ + 'name' => $name, + 'project_type' => 'unknown', + 'includes' => [], + ]; + $this->createFetchTask($project_fetch_data); + if ($this->processFetchTask($project_fetch_data)) { + // If the fetch task was successful return the project information. + return $this->availableReleasesTempStore->get($name); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function processFetchTask($project) { + // The parent method will set 'update.last_check' which will be used to + // inform the user when the last time update information was checked. In + // order to leave this value unaffected we will reset this to its previous + // value. + $last_check = $this->stateStore->get('update.last_check'); + $success = parent::processFetchTask($project); + $this->stateStore->set('update.last_check', $last_check); + return $success; + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/GitExcluder.php b/core/modules/package_manager/src/PathExcluder/GitExcluder.php new file mode 100644 index 000000000000..1e38ad9cf42a --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/GitExcluder.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes .git directories from stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class GitExcluder implements EventSubscriberInterface { + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeGitDirectories', + ]; + } + + /** + * Excludes .git directories from stage operations. + * + * Any .git directories that are a part of an installed package -- for + * example, a module that Composer installed from source -- are included. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + * + * @throws \Exception + * See \Drupal\package_manager\ComposerInspector::validate(). + */ + public function excludeGitDirectories(CollectPathsToExcludeEvent $event): void { + $project_root = $this->pathLocator->getProjectRoot(); + + // To determine which .git directories to exclude, the installed packages + // must be known, and that requires Composer commands to be able to run. + // This intentionally does not catch exceptions: failed Composer validation + // in the project root implies that this excluder cannot function correctly. + // Note: the call to ComposerInspector::getInstalledPackagesList() would + // also have triggered this, but explicitness is preferred here. + // @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck() + $this->composerInspector->validate($project_root); + + $paths_to_exclude = []; + + $installed_paths = []; + // Collect the paths of every installed package. + $installed_packages = $this->composerInspector->getInstalledPackagesList($project_root); + foreach ($installed_packages as $package) { + if (!empty($package->path)) { + $installed_paths[] = $package->path; + } + } + $paths = $event->scanForDirectoriesByName('.git'); + foreach ($paths as $git_directory) { + // Don't exclude any `.git` directory that is directly under an installed + // package's path, since it means Composer probably installed that package + // from source and therefore needs the `.git` directory in order to update + // the package. + if (!in_array(dirname($git_directory), $installed_paths, TRUE)) { + $paths_to_exclude[] = $git_directory; + } + } + $event->addPathsRelativeToProjectRoot($paths_to_exclude); + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/NodeModulesExcluder.php b/core/modules/package_manager/src/PathExcluder/NodeModulesExcluder.php new file mode 100644 index 000000000000..f8eb6ffd5081 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/NodeModulesExcluder.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes node_modules files from stage directories. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class NodeModulesExcluder implements EventSubscriberInterface { + + /** + * Excludes node_modules directories from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeNodeModulesFiles(CollectPathsToExcludeEvent $event): void { + $event->addPathsRelativeToProjectRoot($event->scanForDirectoriesByName('node_modules')); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeNodeModulesFiles', + ]; + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php new file mode 100644 index 000000000000..0f38fb2a7b19 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\Core\File\Exception\FileException; +use Drupal\Core\File\FileSystemInterface; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes site configuration files from stage directories. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class SiteConfigurationExcluder implements EventSubscriberInterface { + + public function __construct( + protected string $sitePath, + private readonly PathLocator $pathLocator, + private readonly FileSystemInterface $fileSystem, + ) {} + + /** + * Excludes site configuration files from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeSiteConfiguration(CollectPathsToExcludeEvent $event): void { + // These two files are never relevant to existing sites. + $paths = [ + 'sites/default/default.settings.php', + 'sites/default/default.services.yml', + ]; + + // Exclude site-specific settings files, which are always in the web root. + // By default, Drupal core will always try to write-protect these files. + // @see system_requirements() + $settings_files = [ + 'settings.php', + 'settings.local.php', + 'services.yml', + ]; + foreach ($settings_files as $settings_file) { + $paths[] = $this->sitePath . '/' . $settings_file; + $paths[] = 'sites/default/' . $settings_file; + } + // Site configuration files are always excluded relative to the web root. + $event->addPathsRelativeToWebRoot($paths); + } + + /** + * Makes the staged `sites/default` directory owner-writable. + * + * This allows the core scaffold plugin to make changes in `sites/default`, + * if needed. Otherwise, it would break if `sites/default` is not writable. + * This can happen because rsync preserves directory permissions (and Drupal + * tries to write-protect the site directory). + * + * We specifically exclude `default.settings.php` and `default.services.yml` + * from Package Manager operations. This allows the scaffold plugin to change + * those files in the stage directory. + * + * @param \Drupal\package_manager\Event\PostCreateEvent $event + * The event being handled. + * + * @see ::excludeSiteConfiguration() + */ + public function makeDefaultSiteDirectoryWritable(PostCreateEvent $event): void { + $dir = $this->getDefaultSiteDirectoryPath($event->stage->getStageDirectory()); + // If the directory doesn't even exist, there's nothing to do here. + if (!is_dir($dir)) { + return; + } + if (!$this->fileSystem->chmod($dir, 0700)) { + throw new FileException("Could not change permissions on '$dir'."); + } + } + + /** + * Makes `sites/default` permissions the same in live and stage directories. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event being handled. + * + * @throws \Drupal\Core\File\Exception\FileException + * If the permissions of the live `sites/default` cannot be determined, or + * cannot be changed on the staged `sites/default`. + */ + public function syncDefaultSiteDirectoryPermissions(PreApplyEvent $event): void { + $staged_dir = $this->getDefaultSiteDirectoryPath($event->stage->getStageDirectory()); + // If the directory doesn't even exist, there's nothing to do here. + if (!is_dir($staged_dir)) { + return; + } + $live_dir = $this->getDefaultSiteDirectoryPath($this->pathLocator->getProjectRoot()); + + $permissions = fileperms($live_dir); + if ($permissions === FALSE) { + throw new FileException("Could not determine permissions for '$live_dir'."); + } + + if (!$this->fileSystem->chmod($staged_dir, $permissions)) { + throw new FileException("Could not change permissions on '$staged_dir'."); + } + } + + /** + * Returns the full path to `sites/default`, relative to a root directory. + * + * @param string $root_dir + * The root directory. + * + * @return string + * The full path to `sites/default` within the given root directory. + */ + private function getDefaultSiteDirectoryPath(string $root_dir): string { + $dir = [$root_dir]; + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $dir[] = $web_root; + } + return implode(DIRECTORY_SEPARATOR, [...$dir, 'sites', 'default']); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeSiteConfiguration', + PostCreateEvent::class => 'makeDefaultSiteDirectoryWritable', + PreApplyEvent::class => 'syncDefaultSiteDirectoryPermissions', + ]; + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php new file mode 100644 index 000000000000..15b6295e95d6 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\Core\StreamWrapper\LocalStream; +use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Excludes site files from stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class SiteFilesExcluder implements EventSubscriberInterface { + + public function __construct( + private readonly StreamWrapperManagerInterface $streamWrapperManager, + private readonly Filesystem $fileSystem, + private readonly array $wrappers, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeSiteFiles', + ]; + } + + /** + * Excludes public and private files from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeSiteFiles(CollectPathsToExcludeEvent $event): void { + // Exclude files handled by the stream wrappers listed in $this->wrappers. + // These paths could be either absolute or relative, depending on site + // settings. If they are absolute, treat them as relative to the project + // root. Otherwise, treat them as relative to the web root. + foreach ($this->wrappers as $scheme) { + $wrapper = $this->streamWrapperManager->getViaScheme($scheme); + if ($wrapper instanceof LocalStream) { + $path = $wrapper->getDirectoryPath(); + + if ($this->fileSystem->isAbsolutePath($path)) { + if ($path = realpath($path)) { + $event->addPathsRelativeToProjectRoot([$path]); + } + } + else { + $event->addPathsRelativeToWebRoot([$path]); + } + } + } + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php new file mode 100644 index 000000000000..47f5d6652682 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\Core\Database\Connection; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes SQLite database files from stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class SqliteDatabaseExcluder implements EventSubscriberInterface { + + public function __construct( + private readonly PathFactoryInterface $pathFactory, + private readonly Connection $database, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeDatabaseFiles', + ]; + } + + /** + * Excludes SQLite database files from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeDatabaseFiles(CollectPathsToExcludeEvent $event): void { + // If the database is SQLite, it might be located in the project directory, + // and should be excluded. + if ($this->database->driver() === 'sqlite') { + // @todo Support database connections other than the default in + // https://www.drupal.org/i/3441919. + $db_path = $this->database->getConnectionOptions()['database']; + // Exclude the database file and auxiliary files created by SQLite. + $paths = [$db_path, "$db_path-shm", "$db_path-wal"]; + + // If the database path is absolute, it might be outside the project root, + // in which case we don't need to do anything. + if ($this->pathFactory->create($db_path)->isAbsolute()) { + try { + $event->addPathsRelativeToProjectRoot($paths); + } + catch (\LogicException) { + // The database is outside the project root, so we're done. + } + } + else { + // The database is in the web root, and must be excluded relative to it. + $event->addPathsRelativeToWebRoot($paths); + } + } + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php b/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php new file mode 100644 index 000000000000..cb6aefc3066f --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes 'sites/simpletest' from stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class TestSiteExcluder implements EventSubscriberInterface { + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeTestSites', + ]; + } + + /** + * Excludes sites/simpletest from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeTestSites(CollectPathsToExcludeEvent $event): void { + // Always exclude automated test directories. If they exist, they will be in + // the web root. + $event->addPathsRelativeToWebRoot(['sites/simpletest']); + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php b/core/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php new file mode 100644 index 000000000000..f6babf1814e5 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php @@ -0,0 +1,201 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Filesystem\Exception\InvalidArgumentException; +use Symfony\Component\Filesystem\Path; + +/** + * Excludes unknown paths from stage operations. + * + * Any path in the root directory of the project that is NOT one of the + * following are considered unknown paths: + * 1. The vendor directory + * 2. The web root + * 3. composer.json + * 4. composer.lock + * 5. Scaffold files as determined by the drupal/core-composer-scaffold plugin + * + * If the web root and the project root are the same, nothing is excluded. + * + * This excluder can be disabled by changing the config setting + * `package_manager.settings:include_unknown_files_in_project_root` to TRUE. + * This may be needed for sites that have files outside the web root (besides + * the vendor directory) which are nonetheless needed in order for Composer to + * assemble the code base correctly; a classic example would be a directory of + * patch files used by `cweagans/composer-patches`. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class UnknownPathExcluder implements EventSubscriberInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + use StringTranslationTrait; + + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeUnknownPaths', + StatusCheckEvent::class => 'logExcludedPaths', + ]; + } + + /** + * Returns the paths to exclude from stage operations. + * + * @return string[] + * The paths that should be excluded from stage operations, relative to the + * project root. + * + * @throws \Exception + * See \Drupal\package_manager\ComposerInspector::validate(). + */ + private function getExcludedPaths(): array { + // If this excluder is disabled, or the project root and web root are the + // same, we are not excluding any paths. + $is_disabled = $this->configFactory->get('package_manager.settings') + ->get('include_unknown_files_in_project_root'); + $web_root = $this->pathLocator->getWebRoot(); + if ($is_disabled || empty($web_root)) { + return []; + } + + // To determine the files to include, the installed packages must be known, + // and that requires Composer commands to be able to run. This intentionally + // does not catch exceptions: failed Composer validation in the project root + // implies that this excluder cannot function correctly. In such a case, the + // call to ComposerInspector::getConfig() would also have triggered an + // exception, but explicitness is preferred here. + // @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck() + $project_root = $this->pathLocator->getProjectRoot(); + $this->composerInspector->validate($project_root); + + // The vendor directory and web root are always included in staging + // operations, along with `composer.json`, `composer.lock`, and any scaffold + // files provided by Drupal core. + $always_include = [ + $this->composerInspector->getConfig('vendor-dir', $project_root), + $web_root, + 'composer.json', + 'composer.lock', + ]; + foreach ($this->getScaffoldFiles() as $scaffold_file_path) { + // The web root is always included in staging operations, so we don't need + // to do anything special for scaffold files that live in it. + if (str_starts_with($scaffold_file_path, '[web-root]')) { + continue; + } + $always_include[] = ltrim($scaffold_file_path, '/'); + } + + // Find any path repositories located inside the project root. These need + // to be included or Composer will break in the staging area. + $repositories = $this->composerInspector->getConfig('repositories', $project_root); + $repositories = Json::decode($repositories); + foreach ($repositories as $repository) { + if ($repository['type'] !== 'path') { + continue; + } + try { + // Ensure $path is relative to the project root, even if it's written as + // an absolute path in `composer.json`. + $path = Path::makeRelative($repository['url'], $project_root); + // Strip off everything except the top-level directory name. For + // example, if the repository path is `custom/module/foo`, always + // include `custom`. + $always_include[] = dirname($path, substr_count($path, '/') ?: 1); + } + catch (InvalidArgumentException) { + // The repository path is not relative to the project root, so we don't + // need to worry about it. + } + } + + // Search for all files (including hidden ones) in the project root. We need + // to use readdir() and friends here, rather than glob(), since certain + // glob() flags aren't supported on all systems. We also can't use + // \Drupal\Core\File\FileSystemInterface::scanDirectory(), because it + // unconditionally ignores hidden files and directories. + $files_in_project_root = []; + $handle = opendir($project_root); + if (empty($handle)) { + throw new \RuntimeException("Could not scan for files in the project root."); + } + while ($entry = readdir($handle)) { + $files_in_project_root[] = $entry; + } + closedir($handle); + + return array_diff($files_in_project_root, $always_include, ['.', '..']); + } + + /** + * Excludes unknown paths from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeUnknownPaths(CollectPathsToExcludeEvent $event): void { + // We can exclude the paths as-is; they are already relative to the project + // root. + $event->add(...$this->getExcludedPaths()); + } + + /** + * Logs the paths that will be excluded from stage operations. + */ + public function logExcludedPaths(): void { + $excluded_paths = $this->getExcludedPaths(); + if ($excluded_paths) { + sort($excluded_paths); + + $message = $this->t("The following paths in @project_root aren't recognized as part of your Drupal site, so to be safe, Package Manager is excluding them from all stage operations. If these files are not needed for Composer to work properly in your site, no action is needed. Otherwise, you can disable this behavior by setting the <code>package_manager.settings:include_unknown_files_in_project_root</code> config setting to <code>TRUE</code>.\n\n@list", [ + '@project_root' => $this->pathLocator->getProjectRoot(), + '@list' => implode("\n", $excluded_paths), + ]); + $this->logger?->info($message); + } + } + + /** + * Gets the path of scaffold files, for example 'index.php' and 'robots.txt'. + * + * @return string[] + * The paths of scaffold files provided by `drupal/core`, relative to the + * project root. + * + * @todo Intelligently load scaffold files in https://drupal.org/i/3343802. + */ + private function getScaffoldFiles(): array { + $project_root = $this->pathLocator->getProjectRoot(); + $packages = $this->composerInspector->getInstalledPackagesList($project_root); + $extra = Json::decode($this->composerInspector->getConfig('extra', $packages['drupal/core']->path . '/composer.json')); + + $scaffold_files = $extra['drupal-scaffold']['file-mapping'] ?? []; + return str_replace('[project-root]', '', array_keys($scaffold_files)); + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php b/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php new file mode 100644 index 000000000000..6bbe26e47802 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\PathExcluder; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes vendor hardening files from stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class VendorHardeningExcluder implements EventSubscriberInterface { + + public function __construct(private readonly PathLocator $pathLocator) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'excludeVendorHardeningFiles', + ]; + } + + /** + * Excludes vendor hardening files from stage operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event object. + */ + public function excludeVendorHardeningFiles(CollectPathsToExcludeEvent $event): void { + // If the core-vendor-hardening plugin (used in the legacy-project template) + // is present, it may have written security hardening files in the vendor + // directory. They should always be excluded. + $vendor_dir = $this->pathLocator->getVendorDirectory(); + $event->addPathsRelativeToProjectRoot([$vendor_dir . '/.htaccess']); + } + +} diff --git a/core/modules/package_manager/src/PathLocator.php b/core/modules/package_manager/src/PathLocator.php new file mode 100644 index 000000000000..a41e767f814c --- /dev/null +++ b/core/modules/package_manager/src/PathLocator.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\Autoload\ClassLoader; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; + +/** + * Computes file system paths that are needed to stage code changes. + */ +class PathLocator { + + public function __construct( + protected string $appRoot, + protected ConfigFactoryInterface $configFactory, + protected FileSystemInterface $fileSystem, + ) {} + + /** + * Returns the absolute path of the project root. + * + * This is where the project-level composer.json should normally be found, and + * may or may not be the same path as the Drupal code base. + * + * @return string + * The absolute path of the project root. + */ + public function getProjectRoot(): string { + // Assume that the vendor directory is immediately below the project root. + return realpath($this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..'); + } + + /** + * Returns the absolute path of the vendor directory. + * + * @return string + * The absolute path of the vendor directory. + */ + public function getVendorDirectory(): string { + // There may be multiple class loaders at work. + // ClassLoader::getRegisteredLoaders() keeps track of them all, indexed by + // the path of the vendor directory they load classes from. + $loaders = ClassLoader::getRegisteredLoaders(); + + // If there's only one class loader, we don't need to search for the right + // one. + if (count($loaders) === 1) { + return key($loaders); + } + + // To determine which class loader is the one for Drupal's vendor directory, + // look for the loader whose vendor path starts the same way as the path to + // this file. + foreach (array_keys($loaders) as $path) { + if (str_starts_with(__FILE__, dirname($path))) { + return $path; + } + } + // If we couldn't find a match, assume that the first registered class + // loader is the one we want. + return key($loaders); + } + + /** + * Returns the path of the Drupal installation, relative to the project root. + * + * @return string + * The path of the Drupal installation, relative to the project root and + * without leading or trailing slashes. Will return an empty string if the + * project root and Drupal root are the same. + */ + public function getWebRoot(): string { + $web_root = str_replace(trim($this->getProjectRoot(), DIRECTORY_SEPARATOR), '', trim($this->appRoot, DIRECTORY_SEPARATOR)); + return trim($web_root, DIRECTORY_SEPARATOR); + } + + /** + * Returns the directory where stage directories will be created. + * + * The stage root may be affected by site settings, so stages may wish to + * cache the value returned by this method, to ensure that they use the same + * stage root directory throughout their life cycle. + * + * @return string + * The absolute path of the directory where stage directories should be + * created. + */ + public function getStagingRoot(): string { + $site_id = $this->configFactory->get('system.site')->get('uuid'); + return $this->fileSystem->getTempDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id; + } + +} diff --git a/core/modules/package_manager/src/Plugin/QueueWorker/Cleaner.php b/core/modules/package_manager/src/Plugin/QueueWorker/Cleaner.php new file mode 100644 index 000000000000..a64dd6730b56 --- /dev/null +++ b/core/modules/package_manager/src/Plugin/QueueWorker/Cleaner.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\package_manager\Plugin\QueueWorker; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Queue\QueueWorkerBase; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Processes a queue of defunct stage directories, deleting them. + * + * @QueueWorker( + * id = "package_manager_cleanup", + * title = @Translation("Stage directory cleaner"), + * cron = {"time" = 30} + * ) + */ +final class Cleaner extends QueueWorkerBase implements ContainerFactoryPluginInterface { + + public function __construct(array $configuration, string $plugin_id, mixed $plugin_definition, private readonly FileSystemInterface $fileSystem) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(FileSystemInterface::class), + ); + } + + /** + * {@inheritdoc} + */ + public function processItem($dir): void { + assert(is_string($dir)); + + if (file_exists($dir)) { + $this->fileSystem->deleteRecursive($dir, function (string $path): void { + $this->fileSystem->chmod($path, is_dir($path) ? 0700 : 0600); + }); + } + } + +} diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php new file mode 100644 index 000000000000..7e92977e7ff8 --- /dev/null +++ b/core/modules/package_manager/src/ProcessFactory.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +// cspell:ignore BINDIR + +/** + * Defines a process factory which sets the COMPOSER_HOME environment variable. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ProcessFactory implements ProcessFactoryInterface { + + public function __construct( + private readonly FileSystemInterface $fileSystem, + private readonly ConfigFactoryInterface $configFactory, + private readonly ProcessFactoryInterface $decorated, + ) {} + + /** + * {@inheritdoc} + */ + public function create(array $command, ?PathInterface $cwd = NULL, array $env = []): ProcessInterface { + $process = $this->decorated->create($command, $cwd, $env); + + $env = $process->getEnv(); + if ($command && $this->isComposerCommand($command)) { + $env['COMPOSER_HOME'] = $this->getComposerHomePath(); + } + // Ensure that the current PHP installation is the first place that will be + // searched when looking for the PHP interpreter. + $env['PATH'] = static::getPhpDirectory() . ':' . getenv('PATH'); + $process->setEnv($env); + return $process; + } + + /** + * Returns the directory which contains the PHP interpreter. + * + * @return string + * The path of the directory containing the PHP interpreter. If the server + * is running in a command-line interface, the directory portion of + * PHP_BINARY is returned; otherwise, the compile-time PHP_BINDIR is. + * + * @see php_sapi_name() + * @see https://www.php.net/manual/en/reserved.constants.php + */ + private static function getPhpDirectory(): string { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') { + return dirname(PHP_BINARY); + } + return PHP_BINDIR; + } + + /** + * Returns the path to use as the COMPOSER_HOME environment variable. + * + * @return string + * The path which should be used as COMPOSER_HOME. + */ + private function getComposerHomePath(): string { + $home_path = $this->fileSystem->getTempDirectory(); + $home_path .= '/package_manager_composer_home-'; + $home_path .= $this->configFactory->get('system.site')->get('uuid'); + $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + + return $home_path; + } + + /** + * Determines if a command is running Composer. + * + * @param string[] $command + * The command parts. + * + * @return bool + * TRUE if the command is running Composer, FALSE otherwise. + */ + private function isComposerCommand(array $command): bool { + $executable = $command[0]; + $executable_parts = explode('/', $executable); + $file = array_pop($executable_parts); + return str_starts_with($file, 'composer'); + } + +} diff --git a/core/modules/package_manager/src/ProcessOutputCallback.php b/core/modules/package_manager/src/ProcessOutputCallback.php new file mode 100644 index 000000000000..9a286623f165 --- /dev/null +++ b/core/modules/package_manager/src/ProcessOutputCallback.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; + +/** + * A process callback for capturing output. + * + * @see \Symfony\Component\Process\Process + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ProcessOutputCallback implements OutputCallbackInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + + /** + * The output buffer. + * + * @var array + */ + private array $outBuffer = []; + + /** + * The error buffer. + * + * @var array + */ + private array $errorBuffer = []; + + /** + * Constructs a ProcessOutputCallback object. + */ + public function __construct() { + $this->setLogger(new NullLogger()); + } + + /** + * {@inheritdoc} + */ + public function __invoke(OutputTypeEnum $type, string $buffer): void { + + if ($type === OutputTypeEnum::OUT) { + $this->outBuffer[] = $buffer; + } + elseif ($type === OutputTypeEnum::ERR) { + $this->errorBuffer[] = $buffer; + } + } + + /** + * Gets the output. + * + * If there is anything in the error buffer, it will be logged as a warning. + * + * @return array + * The output buffer. + */ + public function getOutput(): array { + $error_output = $this->getErrorOutput(); + if ($error_output) { + $this->logger->warning(implode('', $error_output)); + } + return $this->outBuffer; + } + + /** + * Gets the parsed JSON output. + * + * @return mixed + * The decoded JSON output or NULL if there isn't any. + */ + public function parseJsonOutput(): mixed { + $output = $this->getOutput(); + if ($output) { + return json_decode(trim(implode('', $output)), TRUE, flags: JSON_THROW_ON_ERROR); + } + return NULL; + } + + /** + * Gets the error output. + * + * @return array + * The error output buffer. + */ + public function getErrorOutput(): array { + return $this->errorBuffer; + } + + /** + * {@inheritdoc} + */ + public function clearErrorOutput(): void { + $this->errorBuffer = []; + } + + /** + * {@inheritdoc} + */ + public function clearOutput(): void { + $this->outBuffer = []; + } + + /** + * Resets buffers. + * + * @return self + */ + public function reset(): self { + $this->clearErrorOutput(); + $this->clearOutput(); + return $this; + } + +} diff --git a/core/modules/package_manager/src/ProjectInfo.php b/core/modules/package_manager/src/ProjectInfo.php new file mode 100644 index 000000000000..c574e5fb98e8 --- /dev/null +++ b/core/modules/package_manager/src/ProjectInfo.php @@ -0,0 +1,230 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\Semver\Comparator; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\update\ProjectRelease; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\Utility\Error; +use Drupal\update\UpdateManagerInterface; + +/** + * Defines a class for retrieving project information from Update module. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should use the Update API + * directly. + */ +final class ProjectInfo { + + public function __construct(private readonly string $name) { + } + + /** + * Determines if a release can be installed. + * + * @param \Drupal\update\ProjectRelease $release + * The project release. + * @param string[] $support_branches + * The supported branches. + * + * @return bool + * TRUE if the release is installable, otherwise FALSE. A release will be + * considered installable if it is secure, published, supported, and in + * a supported branch. + */ + private function isInstallable(ProjectRelease $release, array $support_branches): bool { + if ($release->isInsecure() || !$release->isPublished() || $release->isUnsupported()) { + return FALSE; + } + $version = ExtensionVersion::createFromVersionString($release->getVersion()); + if ($version->getVersionExtra() === 'dev') { + return FALSE; + } + foreach ($support_branches as $support_branch) { + $support_branch_version = ExtensionVersion::createFromSupportBranch($support_branch); + if ($support_branch_version->getMajorVersion() === $version->getMajorVersion() && $support_branch_version->getMinorVersion() === $version->getMinorVersion()) { + return TRUE; + } + } + return FALSE; + } + + /** + * Returns up-to-date project information. + * + * @return mixed[]|null + * The retrieved project information. + * + * @throws \RuntimeException + * If data about available updates cannot be retrieved. + */ + public function getProjectInfo(): ?array { + $available_updates = $this->getAvailableProjects(); + $project_data = update_calculate_project_data($available_updates); + if (!isset($project_data[$this->name])) { + return $available_updates[$this->name] ?? NULL; + } + return $project_data[$this->name]; + } + + /** + * Gets all project releases to which the site can update. + * + * @return \Drupal\update\ProjectRelease[]|null + * If the project information is available, an array of releases that can be + * installed, keyed by version number; otherwise NULL. The releases are in + * descending order by version number (i.e., higher versions are listed + * first). The currently installed version of the project, and any older + * versions, are not considered installable releases. + * + * @throws \RuntimeException + * Thrown if there are no available releases. + * + * @todo Remove or simplify this function in https://www.drupal.org/i/3252190. + */ + public function getInstallableReleases(): ?array { + $project = $this->getProjectInfo(); + if (!$project) { + return NULL; + } + $available_updates = $this->getAvailableProjects()[$this->name]; + if ($available_updates['project_status'] !== 'published') { + throw new \RuntimeException("The project '{$this->name}' can not be updated because its status is " . $available_updates['project_status']); + } + $installed_version = $this->getInstalledVersion(); + + if ($installed_version && empty($available_updates['releases'])) { + // If project is installed but not current we should always have at + // least one release. + throw new \RuntimeException('There was a problem getting update information. Try again later.'); + } + + $support_branches = explode(',', $available_updates['supported_branches']); + $installable_releases = []; + foreach ($available_updates['releases'] as $release_info) { + try { + $release = ProjectRelease::createFromArray($release_info); + } + catch (\UnexpectedValueException $exception) { + // Ignore releases that are in an invalid format. Although this is + // unlikely we should still only process releases in the correct format. + \Drupal::logger('package_manager') + ->error(sprintf('Invalid project format: %s', print_r($release_info, TRUE)), Error::decodeException($exception)); + continue; + } + + $version = $release->getVersion(); + if ($installed_version) { + $semantic_version = LegacyVersionUtility::convertToSemanticVersion($version); + $semantic_installed_version = LegacyVersionUtility::convertToSemanticVersion($installed_version); + if (Comparator::lessThanOrEqualTo($semantic_version, $semantic_installed_version)) { + // If the project is installed stop searching for releases as soon as + // we find the installed version. + break; + } + } + if ($this->isInstallable($release, $support_branches)) { + $installable_releases[$version] = $release; + } + } + return $installable_releases; + } + + /** + * Returns the installed project version, according to the Update module. + * + * @return string|null + * The installed project version as known to the Update module or NULL if + * the project information is not available. + */ + public function getInstalledVersion(): ?string { + $project_data = $this->getProjectInfo(); + if ($project_data && array_key_exists('existing_version', $project_data)) { + $existing_version = $project_data['existing_version']; + // Treat an unknown version the same as a project whose project + // information is not available, so return NULL. + // @see \update_process_project_info() + if ($existing_version instanceof TranslatableMarkup && $existing_version->getUntranslatedString() === 'Unknown') { + return NULL; + } + + // TRICKY: Since this is relying on data coming from + // \Drupal\update\UpdateManager::getProjects(), we cannot be certain that + // we are actually receiving strings. + // @see \Drupal\update\UpdateManager::getProjects() + if (!is_string($existing_version)) { + return NULL; + } + + return $existing_version; + } + return NULL; + } + + /** + * Gets the available projects. + * + * @return array + * The available projects keyed by project machine name in the format + * returned by update_get_available(). If the project specified in ::name is + * not returned from update_get_available() this project will be explicitly + * fetched and added the return value of this function. + * + * @see \update_get_available() + */ + private function getAvailableProjects(): array { + $available_projects = update_get_available(TRUE); + // update_get_available() will only returns projects that are in the active + // codebase. If the project specified by ::name is not returned in + // $available_projects, it means it is not in the active codebase, therefore + // we will retrieve the project information from Package Manager's own + // update processor service. + if (!isset($available_projects[$this->name])) { + /** @var \Drupal\package_manager\PackageManagerUpdateProcessor $update_processor */ + $update_processor = \Drupal::service(PackageManagerUpdateProcessor::class); + if ($project_data = $update_processor->getProjectData($this->name)) { + $available_projects[$this->name] = $project_data; + } + } + return $available_projects; + } + + /** + * Checks if the installed version of this project is safe to use. + * + * @return bool + * TRUE if the installed version of this project is secure, supported, and + * published. Otherwise, or if the project information could not be + * retrieved, returns FALSE. + */ + public function isInstalledVersionSafe(): bool { + $project_data = $this->getProjectInfo(); + if ($project_data) { + $unsafe = [ + UpdateManagerInterface::NOT_SECURE, + UpdateManagerInterface::NOT_SUPPORTED, + UpdateManagerInterface::REVOKED, + ]; + return !in_array($project_data['status'], $unsafe, TRUE); + } + // If we couldn't get project data, assume the installed version is unsafe. + return FALSE; + } + + /** + * Gets the supported branches of the project. + * + * @return string[] + * The supported branches. + */ + public function getSupportedBranches(): array { + $available_updates = $this->getAvailableProjects()[$this->name]; + return isset($available_updates['supported_branches']) ? explode(',', $available_updates['supported_branches']) : []; + } + +} diff --git a/core/modules/package_manager/src/StageBase.php b/core/modules/package_manager/src/StageBase.php new file mode 100644 index 000000000000..de70abeec92c --- /dev/null +++ b/core/modules/package_manager/src/StageBase.php @@ -0,0 +1,851 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Composer\Semver\VersionParser; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Component\Utility\Random; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TempStore\SharedTempStore; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use Drupal\Core\Utility\Error; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\ApplyFailedException; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Creates and manages a stage directory in which to install or update code. + * + * Allows calling code to copy the current Drupal site into a temporary stage + * directory, use Composer to require packages into it, sync changes from the + * stage directory back into the active code base, and then delete the + * stage directory. + * + * Only one stage directory can exist at any given time, and the stage is + * owned by the user or session that originally created it. Only the owner can + * perform operations on the stage directory, and the stage must be "claimed" + * by its owner before any such operations are done. A stage is claimed by + * presenting a unique token that is generated when the stage is created. + * + * Although a site can only have one stage directory, it is possible for + * privileged users to destroy a stage created by another user. To prevent such + * actions from putting the file system into an uncertain state (for example, if + * a stage is destroyed by another user while it is still being created), the + * stage directory has a randomly generated name. For additional cleanliness, + * all stage directories created by a specific site live in a single directory + * ,called the "stage root directory" and identified by the UUID of the current + * site (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage + * created by that site is destroyed. + */ +abstract class StageBase implements LoggerAwareInterface { + + use LoggerAwareTrait; + use StringTranslationTrait; + + /** + * The tempstore key under which to store the locking info for this stage. + * + * @var string + */ + final protected const TEMPSTORE_LOCK_KEY = 'lock'; + + /** + * The tempstore key under which to store arbitrary metadata for this stage. + * + * @var string + */ + final protected const TEMPSTORE_METADATA_KEY = 'metadata'; + + /** + * The tempstore key under which to store the path of stage root directory. + * + * @var string + * + * @see ::getStagingRoot() + */ + private const TEMPSTORE_STAGING_ROOT_KEY = 'staging_root'; + + /** + * The tempstore key under which to store the time that ::apply() was called. + * + * @var string + * + * @see ::apply() + * @see ::destroy() + */ + private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time'; + + /** + * The tempstore key for whether staged operations have been applied. + * + * @var string + * + * @see ::apply() + * @see ::destroy() + */ + private const TEMPSTORE_CHANGES_APPLIED = 'changes_applied'; + + /** + * The tempstore key for information about previously destroyed stages. + * + * @var string + * + * @see ::apply() + * @see ::destroy() + */ + private const TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX = 'TEMPSTORE_DESTROYED_STAGES_INFO'; + + /** + * The regular expression to check if a package name is a platform package. + * + * @var string + * + * @see \Composer\Repository\PlatformRepository::PLATFORM_PACKAGE_REGEX + * @see ::validateRequirements() + */ + private const COMPOSER_PLATFORM_PACKAGE_REGEX = '{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD'; + + /** + * The regular expression to check if a package name is a regular package. + * + * If you try to require an invalid package name, this is the regular + * expression that Composer will, at the command line, tell you to match. + * + * @var string + * + * @see \Composer\Package\Loader\ValidatingArrayLoader::hasPackageNamingError() + * @see ::validateRequirements() + */ + private const COMPOSER_PACKAGE_REGEX = '/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]?|-{0,2})[a-z0-9]+)*$/'; + + /** + * The lock info for the stage. + * + * Consists of a unique random string and the current class name. + * + * @var string[] + */ + private $lock; + + /** + * The shared temp store. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected SharedTempStore $tempStore; + + /** + * The stage type. + * + * To ensure that stage classes do not unintentionally use another stage's + * type, all concrete subclasses MUST explicitly define this property. + * The recommended pattern is `MODULE:TYPE`. + * + * @var string + */ + protected string $type; + + public function __construct( + protected readonly PathLocator $pathLocator, + protected readonly BeginnerInterface $beginner, + protected readonly StagerInterface $stager, + protected readonly CommitterInterface $committer, + protected readonly QueueFactory $queueFactory, + protected EventDispatcherInterface $eventDispatcher, + protected readonly SharedTempStoreFactory $tempStoreFactory, + protected readonly TimeInterface $time, + protected readonly PathFactoryInterface $pathFactory, + protected readonly FailureMarker $failureMarker, + ) { + $this->tempStore = $tempStoreFactory->get('package_manager_stage'); + } + + /** + * Gets the stage type. + * + * The stage type can be used by stage event subscribers to implement logic + * specific to certain stages, without relying on the class name (which may + * not be part of module's public API). + * + * @return string + * The stage type. + * + * @throws \LogicException + * Thrown if $this->type is not explicitly overridden. + */ + final public function getType(): string { + $reflector = new \ReflectionProperty($this, 'type'); + + // The $type property must ALWAYS be overridden. This means that different + // subclasses can return the same value (thus allowing one stage to + // impersonate another one), but if that happens, it is intentional. + if ($reflector->getDeclaringClass()->getName() === static::class) { + return $this->type; + } + throw new \LogicException(static::class . ' must explicitly override the $type property.'); + } + + /** + * Determines if the stage directory can be created. + * + * @return bool + * TRUE if the stage directory can be created, otherwise FALSE. + */ + final public function isAvailable(): bool { + return empty($this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY)); + } + + /** + * Returns a specific piece of metadata associated with this stage. + * + * Only the owner of the stage can access metadata, and the stage must either + * be claimed by its owner, or created during the current request. + * + * @param string $key + * The metadata key. + * + * @return mixed + * The metadata value, or NULL if it is not set. + */ + public function getMetadata(string $key) { + $this->checkOwnership(); + + $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY) ?: []; + return $metadata[$key] ?? NULL; + } + + /** + * Stores arbitrary metadata associated with this stage. + * + * Only the owner of the stage can set metadata, and the stage must either be + * claimed by its owner, or created during the current request. + * + * @param string $key + * The key under which to store the metadata. To prevent conflicts, it is + * strongly recommended that this be prefixed with the name of the module + * storing the data. + * @param mixed $data + * The metadata to store. + */ + public function setMetadata(string $key, $data): void { + $this->checkOwnership(); + + $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY); + $metadata[$key] = $data; + $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, $metadata); + } + + /** + * Collects paths that Composer Stager should exclude. + * + * @return \PhpTuf\ComposerStager\API\Path\Value\PathListInterface + * A list of paths that Composer Stager should exclude when creating the + * stage directory and applying staged changes to the active directory. + * + * @throws \Drupal\package_manager\Exception\StageException + * Thrown if an exception occurs while collecting paths to exclude. + * + * @see ::create() + * @see ::apply() + */ + protected function getPathsToExclude(): PathListInterface { + $event = new CollectPathsToExcludeEvent($this, $this->pathLocator, $this->pathFactory); + try { + return $this->eventDispatcher->dispatch($event); + } + catch (\Throwable $e) { + $this->rethrowAsStageException($e); + } + } + + /** + * Copies the active code base into the stage directory. + * + * This will automatically claim the stage, so external code does NOT need to + * call ::claim(). However, if it was created during another request, the + * stage must be claimed before operations can be performed on it. + * + * @param int|null $timeout + * (optional) How long to allow the file copying operation to run before + * timing out, in seconds, or NULL to never time out. Defaults to 300 + * seconds. + * + * @return string + * Unique ID for the stage, which can be used to claim the stage before + * performing other operations on it. Calling code should store this ID for + * as long as the stage needs to exist. + * + * @throws \Drupal\package_manager\Exception\StageException + * Thrown if a stage directory already exists, or if an error occurs while + * creating the stage directory. In the latter situation, the stage + * directory will be destroyed. + * + * @see ::claim() + */ + public function create(?int $timeout = 300): string { + $this->failureMarker->assertNotExists(); + + if (!$this->isAvailable()) { + throw new StageException($this, 'Cannot create a new stage because one already exists.'); + } + // Mark the stage as unavailable as early as possible, before dispatching + // the pre-create event. The idea is to prevent a race condition if the + // event subscribers take a while to finish, and two different users attempt + // to create a stage directory at around the same time. If an error occurs + // while the event is being processed, the stage is marked as available. + // @see ::dispatch() + // We specifically generate a random 32-character alphanumeric name in order + // to guarantee that the stage ID won't start with -, which could cause it + // to be interpreted as an option if it's used as a command-line argument. + // (For example, \Drupal\Component\Utility\Crypt::randomBytesBase64() would + // be vulnerable to this; the stage ID needs to be unique, but not + // cryptographically so.) + $id = (new Random())->name(32); + // Re-acquire the tempstore to ensure that the lock is written by whoever is + // actually logged in (or not) right now, since it's possible that the stage + // was instantiated (i.e., __construct() was called) by a different session, + // which would result in the lock having the wrong owner and the stage not + // being claimable by whoever is actually creating it. + $this->tempStore = $this->tempStoreFactory->get('package_manager_stage'); + // For the lock value, we use both the stage's class and its type in order + // to prevent a stage from being manipulated by two different classes during + // a single life cycle. + $this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [ + $id, + static::class, + $this->getType(), + ]); + $this->claim($id); + + $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); + $stage_dir = $this->pathFactory->create($this->getStageDirectory()); + + $excluded_paths = $this->getPathsToExclude(); + $event = new PreCreateEvent($this, $excluded_paths); + // If an error occurs and we won't be able to create the stage, mark it as + // available. + $this->dispatch($event, [$this, 'markAsAvailable']); + + try { + $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout); + } + catch (\Throwable $error) { + $this->destroy(); + $this->rethrowAsStageException($error); + } + $this->dispatch(new PostCreateEvent($this)); + return $id; + } + + /** + * Wraps an exception in a StageException and re-throws it. + * + * @param \Throwable $e + * The throwable to wrap. + */ + private function rethrowAsStageException(\Throwable $e): never { + throw new StageException($this, $e->getMessage(), $e->getCode(), $e); + } + + /** + * Adds or updates packages in the stage directory. + * + * @param string[] $runtime + * The packages to add as regular top-level dependencies, in the form + * 'vendor/name' or 'vendor/name:version'. + * @param string[] $dev + * (optional) The packages to add as dev dependencies, in the form + * 'vendor/name' or 'vendor/name:version'. Defaults to an empty array. + * @param int|null $timeout + * (optional) How long to allow the Composer operation to run before timing + * out, in seconds, or NULL to never time out. Defaults to 300 seconds. + * + * @throws \Drupal\package_manager\Exception\StageException + * Thrown if the Composer operation cannot be started, or if an error occurs + * during the operation. In the latter situation, the stage directory will + * be destroyed. + */ + public function require(array $runtime, array $dev = [], ?int $timeout = 300): void { + $this->checkOwnership(); + + $this->dispatch(new PreRequireEvent($this, $runtime, $dev)); + + // A helper function to execute a command in the stage, destroying it if an + // exception occurs in the middle of a Composer operation. + $do_stage = function (array $command) use ($timeout): void { + $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); + $stage_dir = $this->pathFactory->create($this->getStageDirectory()); + + try { + $this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout); + } + catch (\Throwable $e) { + // If the caught exception isn't InvalidArgumentException or + // PreconditionException, a Composer operation was actually attempted, + // and failed. The stage should therefore be destroyed, because it's in + // an indeterminate and possibly unrecoverable state. + if (!$e instanceof InvalidArgumentException && !$e instanceof PreconditionException) { + $this->destroy(); + } + $this->rethrowAsStageException($e); + } + }; + + // Change the runtime and dev requirements as needed, but don't update + // the installed packages yet. + if ($runtime) { + self::validateRequirements($runtime); + $command = array_merge(['require', '--no-update'], $runtime); + $do_stage($command); + } + if ($dev) { + self::validateRequirements($dev); + $command = array_merge(['require', '--dev', '--no-update'], $dev); + $do_stage($command); + } + + // If constraints were changed, update those packages. + if ($runtime || $dev) { + $command = array_merge(['update', '--with-all-dependencies', '--optimize-autoloader'], $runtime, $dev); + $do_stage($command); + } + $this->dispatch(new PostRequireEvent($this, $runtime, $dev)); + } + + /** + * Applies staged changes to the active directory. + * + * After the staged changes are applied, the current request should be + * terminated as soon as possible. This is because the code loaded into the + * PHP runtime may no longer match the code that is physically present in the + * active code base, which means that the current request is running in an + * unreliable, inconsistent environment. In the next request, + * ::postApply() should be called as early as possible after Drupal is + * fully bootstrapped, to rebuild the service container, flush caches, and + * dispatch the post-apply event. + * + * @param int|null $timeout + * (optional) How long to allow the file copying operation to run before + * timing out, in seconds, or NULL to never time out. Defaults to 600 + * seconds. + * + * @throws \Drupal\package_manager\Exception\ApplyFailedException + * Thrown if there is an error calling Composer Stager, which may indicate + * a failed commit operation. + */ + public function apply(?int $timeout = 600): void { + $this->checkOwnership(); + + $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); + $stage_dir = $this->pathFactory->create($this->getStageDirectory()); + + $excluded_paths = $this->getPathsToExclude(); + $event = new PreApplyEvent($this, $excluded_paths); + // If an error occurs while dispatching the events, ensure that ::destroy() + // doesn't think we're in the middle of applying the staged changes to the + // active directory. + $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime()); + $this->dispatch($event, $this->setNotApplying(...)); + + // Create a marker file so that we can tell later on if the commit failed. + $this->failureMarker->write($this, $this->getFailureMarkerMessage()); + + try { + $this->committer->commit($stage_dir, $active_dir, $excluded_paths, NULL, $timeout); + } + catch (InvalidArgumentException | PreconditionException $e) { + // The commit operation has not started yet, so we can clear the failure + // marker and release the flag that says we're applying. + $this->setNotApplying(); + $this->failureMarker->clear(); + $this->rethrowAsStageException($e); + } + catch (\Throwable $throwable) { + // The commit operation may have failed midway through, and the site code + // is in an indeterminate state. Release the flag which says we're still + // applying, because in this situation, the site owner should probably + // restore everything from a backup. + $this->setNotApplying(); + // Update the marker file with the information from the throwable. + $this->failureMarker->write($this, $this->getFailureMarkerMessage(), $throwable); + throw new ApplyFailedException($this, $this->failureMarker->getMessage(), $throwable->getCode(), $throwable); + } + $this->failureMarker->clear(); + $this->setMetadata(self::TEMPSTORE_CHANGES_APPLIED, TRUE); + } + + /** + * Returns a closure that marks this stage as no longer being applied. + */ + private function setNotApplying(): void { + $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY); + } + + /** + * Performs post-apply tasks. + * + * This should be called as soon as possible after ::apply(), in a new + * request. + * + * @see ::apply() + */ + public function postApply(): void { + $this->checkOwnership(); + + if ($this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY) === $this->time->getRequestTime()) { + $this->logger?->warning('Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.'); + } + // Rebuild the container and clear all caches, to ensure that new services + // are picked up. + drupal_flush_all_caches(); + // Refresh the event dispatcher so that new or changed event subscribers + // will be called. The other services we depend on are either stateless or + // unlikely to call newly added code during the current request. + $this->eventDispatcher = \Drupal::service('event_dispatcher'); + + $release_apply = $this->setNotApplying(...); + $this->dispatch(new PostApplyEvent($this), $release_apply); + $release_apply(); + } + + /** + * Deletes the stage directory. + * + * @param bool $force + * (optional) If TRUE, the stage directory will be destroyed even if it is + * not owned by the current user or session. Defaults to FALSE. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message + * (optional) A message about why the stage was destroyed. + * + * @throws \Drupal\package_manager\Exception\StageException + * If the staged changes are being applied to the active directory. + * @throws \Drupal\Core\TempStore\TempStoreException + */ + public function destroy(bool $force = FALSE, ?TranslatableMarkup $message = NULL): void { + if (!$force) { + $this->checkOwnership(); + } + if ($this->isApplying()) { + throw new StageException($this, 'Cannot destroy the stage directory while it is being applied to the active directory.'); + } + + // If the stage directory exists, queue it to be automatically cleaned up + // later by a queue (which may or may not happen during cron). + // @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner + if ($this->stageDirectoryExists()) { + $this->queueFactory->get('package_manager_cleanup') + ->createItem($this->getStageDirectory()); + } + + $this->storeDestroyInfo($force, $message); + $this->markAsAvailable(); + } + + /** + * Marks the stage as available. + */ + protected function markAsAvailable(): void { + $this->tempStore->delete(static::TEMPSTORE_METADATA_KEY); + $this->tempStore->delete(static::TEMPSTORE_LOCK_KEY); + $this->tempStore->delete(self::TEMPSTORE_STAGING_ROOT_KEY); + $this->lock = NULL; + } + + /** + * Dispatches an event and handles any errors that it collects. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + * @param callable|null $on_error + * (optional) A callback function to call if an error occurs, before any + * exceptions are thrown. + * + * @throws \Drupal\package_manager\Exception\StageEventException + * If the event collects any validation errors. + */ + protected function dispatch(StageEvent $event, ?callable $on_error = NULL): void { + try { + $this->eventDispatcher->dispatch($event); + + if ($event instanceof PreOperationStageEvent) { + if ($event->getResults()) { + $error = new StageEventException($event); + } + } + } + catch (\Throwable $error) { + $error = new StageEventException($event, $error->getMessage(), $error->getCode(), $error); + } + + if (isset($error)) { + // Ensure the error is logged for post-mortem diagnostics. + if ($this->logger) { + Error::logException($this->logger, $error); + } + if ($on_error) { + $on_error(); + } + throw $error; + } + } + + /** + * Attempts to claim the stage. + * + * Once a stage has been created, no operations can be performed on it until + * it is claimed. This is to ensure that stage operations across multiple + * requests are being done by the same code, running under the same user or + * session that created the stage in the first place. To claim a stage, the + * calling code must provide the unique identifier that was generated when the + * stage was created. + * + * The stage is claimed when it is created, so external code does NOT need to + * call this method after calling ::create() in the same request. + * + * @param string $unique_id + * The unique ID that was returned by ::create(). + * + * @return $this + * + * @throws \Drupal\package_manager\Exception\StageOwnershipException + * If the stage cannot be claimed. This can happen if the current user or + * session did not originally create the stage, if $unique_id doesn't match + * the unique ID that was generated when the stage was created, or the + * current class is not the same one that was used to create the stage. + * + * @see ::create() + */ + final public function claim(string $unique_id): self { + $this->failureMarker->assertNotExists(); + + if ($this->isAvailable()) { + // phpcs:disable DrupalPractice.General.ExceptionT.ExceptionT + // @see https://www.drupal.org/project/auto_updates/issues/3338651 + throw new StageException($this, $this->computeDestroyMessage( + $unique_id, + $this->t('Cannot claim the stage because no stage has been created.') + )->render()); + } + + $stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY); + if (!$stored_lock) { + throw new StageOwnershipException($this, $this->computeDestroyMessage( + $unique_id, + $this->t('Cannot claim the stage because it is not owned by the current user or session.') + )->render()); + } + + if ($stored_lock === [$unique_id, static::class, $this->getType()]) { + $this->lock = $stored_lock; + return $this; + } + + throw new StageOwnershipException($this, $this->computeDestroyMessage( + $unique_id, + $this->t('Cannot claim the stage because the current lock does not match the stored lock.') + )->render()); + // phpcs:enable DrupalPractice.General.ExceptionT.ExceptionT + } + + /** + * Returns the specific destroy message for the ID. + * + * @param string $unique_id + * The unique ID that was returned by ::create(). + * @param \Drupal\Core\StringTranslation\TranslatableMarkup $fallback_message + * A fallback message, in case no specific message was stored. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * A message describing why the stage with the given ID was destroyed, or if + * no message was associated with that destroyed stage, the provided + * fallback message. + */ + private function computeDestroyMessage(string $unique_id, TranslatableMarkup $fallback_message): TranslatableMarkup { + // Check to see if we have a specific message about a stage with a + // specific ID that was given. + return $this->tempStore->get(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $unique_id) ?? $fallback_message; + } + + /** + * Validates the ownership of stage directory. + * + * The stage is considered under valid ownership if it was created by current + * user or session, using the current class. + * + * @throws \LogicException + * If ::claim() has not been previously called. + * @throws \Drupal\package_manager\Exception\StageOwnershipException + * If the current user or session does not own the stage directory, or it + * was created by a different class. + */ + final protected function checkOwnership(): void { + if (empty($this->lock)) { + throw new \LogicException('Stage must be claimed before performing any operations on it.'); + } + + $stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY); + if ($stored_lock !== $this->lock) { + throw new StageOwnershipException($this, 'Stage is not owned by the current user or session.'); + } + } + + /** + * Returns the path of the directory where changes should be staged. + * + * @return string + * The absolute path of the directory where changes should be staged. + * + * @throws \LogicException + * If this method is called before the stage has been created or claimed. + */ + public function getStageDirectory(): string { + if (!$this->lock) { + throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.'); + } + return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0]; + } + + /** + * Returns the directory where stage directories will be created. + * + * @return string + * The absolute path of the directory containing the stage directories + * managed by this class. + */ + private function getStagingRoot(): string { + // Since the stage root can depend on site settings, store it so that + // things won't break if the settings change during this stage's life + // cycle. + $dir = $this->tempStore->get(self::TEMPSTORE_STAGING_ROOT_KEY); + if (empty($dir)) { + $dir = $this->pathLocator->getStagingRoot(); + $this->tempStore->set(self::TEMPSTORE_STAGING_ROOT_KEY, $dir); + } + return $dir; + } + + /** + * Determines if the stage directory exists. + * + * @return bool + * TRUE if the directory exists, otherwise FALSE. + */ + public function stageDirectoryExists(): bool { + try { + return is_dir($this->getStageDirectory()); + } + catch (\LogicException) { + return FALSE; + } + } + + /** + * Checks if staged changes are being applied to the active directory. + * + * @return bool + * TRUE if the staged changes are being applied to the active directory, and + * it has been less than an hour since that operation began. If more than an + * hour has elapsed since the changes started to be applied, FALSE is + * returned even if the stage internally thinks that changes are still being + * applied. + * + * @see ::apply() + */ + final public function isApplying(): bool { + $apply_time = $this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY); + return isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600; + } + + /** + * Returns the failure marker message. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The translated failure marker message. + */ + protected function getFailureMarkerMessage(): TranslatableMarkup { + return $this->t('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.'); + } + + /** + * Validates a set of package names. + * + * Package names are considered invalid if they look like Drupal project + * names. The only exceptions to this are platform requirements, like `php`, + * `composer`, or `ext-json`, which are legitimate to Composer. + * + * @param string[] $requirements + * A set of package names (with or without version constraints), as passed + * to ::require(). + * + * @throws \InvalidArgumentException + * Thrown if any of the given package names fail basic validation. + */ + protected static function validateRequirements(array $requirements): void { + $version_parser = new VersionParser(); + + foreach ($requirements as $requirement) { + $parts = explode(':', $requirement, 2); + $name = $parts[0]; + + if (!preg_match(self::COMPOSER_PLATFORM_PACKAGE_REGEX, $name) && !preg_match(self::COMPOSER_PACKAGE_REGEX, $name)) { + throw new \InvalidArgumentException("Invalid package name '$name'."); + } + if (count($parts) > 1) { + $version_parser->parseConstraints($parts[1]); + } + } + } + + /** + * Stores information about the stage when it is destroyed. + * + * @param bool $force + * Whether the stage was force destroyed. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message + * A message about why the stage was destroyed or null. + * + * @throws \Drupal\Core\TempStore\TempStoreException + */ + protected function storeDestroyInfo(bool $force, ?TranslatableMarkup $message): void { + if (!$message) { + if ($this->tempStore->get(self::TEMPSTORE_CHANGES_APPLIED) === TRUE) { + $message = $this->t('This operation has already been applied.'); + } + else { + if ($force) { + $message = $this->t('This operation was canceled by another user.'); + } + else { + $message = $this->t('This operation was already canceled.'); + } + } + } + [$id] = $this->tempStore->get(static::TEMPSTORE_LOCK_KEY); + $this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message); + } + +} diff --git a/core/modules/package_manager/src/StatusCheckTrait.php b/core/modules/package_manager/src/StatusCheckTrait.php new file mode 100644 index 000000000000..97c136a28709 --- /dev/null +++ b/core/modules/package_manager/src/StatusCheckTrait.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Contains helper methods to run status checks on a stage. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not use or interact with + * this trait. + */ +trait StatusCheckTrait { + + /** + * Runs a status check for a stage and returns the results, if any. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage to run the status check for. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $event_dispatcher + * (optional) The event dispatcher service. + * @param \Drupal\package_manager\PathLocator|null $path_locator + * (optional) The path locator service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface|null $path_factory + * (optional) The path factory service. + * + * @return \Drupal\package_manager\ValidationResult[] + * The results of the status check. If a readiness check was also done, + * its results will be included. + */ + protected function runStatusCheck(StageBase $stage, ?EventDispatcherInterface $event_dispatcher = NULL, ?PathLocator $path_locator = NULL, ?PathFactoryInterface $path_factory = NULL): array { + $event_dispatcher ??= \Drupal::service('event_dispatcher'); + $path_locator ??= \Drupal::service(PathLocator::class); + $path_factory ??= \Drupal::service(PathFactoryInterface::class); + try { + $paths_to_exclude_event = new CollectPathsToExcludeEvent($stage, $path_locator, $path_factory); + $event_dispatcher->dispatch($paths_to_exclude_event); + } + catch (\Throwable $throwable) { + $paths_to_exclude_event = $throwable; + } + $event = new StatusCheckEvent($stage, $paths_to_exclude_event); + return $event_dispatcher->dispatch($event)->getResults(); + } + +} diff --git a/core/modules/package_manager/src/TranslatableStringAdapter.php b/core/modules/package_manager/src/TranslatableStringAdapter.php new file mode 100644 index 000000000000..7a622bb8f7a6 --- /dev/null +++ b/core/modules/package_manager/src/TranslatableStringAdapter.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use PhpTuf\ComposerStager\API\Translation\Service\TranslatorInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface; + +/** + * An adapter for interoperable string translation. + * + * This class is designed to adapt Drupal's style of string translation so it + * can be used with the Symfony-inspired architecture used by Composer Stager. + * + * If this object is cast to a string, it will be translated by Drupal's + * translation system. It will ONLY be translated by Composer Stager if the + * trans() method is explicitly called. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class TranslatableStringAdapter extends TranslatableMarkup implements TranslatableInterface, TranslationParametersInterface { + + /** + * {@inheritdoc} + */ + public function getAll(): array { + return $this->getArguments(); + } + + /** + * {@inheritdoc} + */ + public function trans(?TranslatorInterface $translator = NULL, ?string $locale = NULL): string { + // This method is NEVER used by Drupal to translate the underlying string; + // it exists solely for Composer Stager's translation system to + // transparently translate Drupal strings using its own architecture. + return $translator->trans( + $this->getUntranslatedString(), + $this, + // The 'context' option is the closest analogue to the Symfony-inspired + // concept of translation domains. + $this->getOption('context'), + $locale ?? $this->getOption('langcode'), + ); + } + +} diff --git a/core/modules/package_manager/src/TranslatableStringFactory.php b/core/modules/package_manager/src/TranslatableStringFactory.php new file mode 100644 index 000000000000..a90eb1ea6a8a --- /dev/null +++ b/core/modules/package_manager/src/TranslatableStringFactory.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\StringTranslation\TranslationInterface; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; +use PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface; + +/** + * Creates translatable strings that can interoperate with Composer Stager. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class TranslatableStringFactory implements TranslatableFactoryInterface { + + public function __construct( + private readonly TranslatableFactoryInterface $decorated, + private readonly TranslationInterface $translation, + ) {} + + /** + * {@inheritdoc} + */ + public function createDomainOptions(): DomainOptionsInterface { + return $this->decorated->createDomainOptions(); + } + + /** + * {@inheritdoc} + */ + public function createTranslatableMessage(string $message, ?TranslationParametersInterface $parameters = NULL, ?string $domain = NULL): TranslatableInterface { + return new TranslatableStringAdapter( + $message, + $parameters?->getAll() ?? [], + // TranslatableMarkup's 'context' option is the closest analogue to the + // $domain parameter. + ['context' => $domain ?? ''], + $this->translation, + ); + } + + /** + * {@inheritdoc} + */ + public function createTranslationParameters(array $parameters = []): TranslationParametersInterface { + return $this->decorated->createTranslationParameters($parameters); + } + +} diff --git a/core/modules/package_manager/src/ValidationResult.php b/core/modules/package_manager/src/ValidationResult.php new file mode 100644 index 000000000000..be540eb7a733 --- /dev/null +++ b/core/modules/package_manager/src/ValidationResult.php @@ -0,0 +1,150 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Component\Assertion\Inspector; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; + +/** + * A value object to contain the results of a validation. + * + * @property \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + */ +final class ValidationResult { + + /** + * Creates a ValidationResult object. + * + * @param int $severity + * The severity of the result. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[] $messages + * The result messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * A succinct summary of the result. + * @param bool $assert_translatable + * Whether to assert the messages are translatable. Internal use only. + * + * @throws \InvalidArgumentException + * Thrown if $messages is empty, or if it has 2 or more items but $summary + * is NULL. + */ + private function __construct( + public readonly int $severity, + private readonly array $messages, + public readonly ?TranslatableMarkup $summary, + bool $assert_translatable, + ) { + if ($assert_translatable) { + assert(Inspector::assertAll(fn ($message) => $message instanceof TranslatableMarkup, $messages)); + } + if (empty($messages)) { + throw new \InvalidArgumentException('At least one message is required.'); + } + if (count($messages) > 1 && !$summary) { + throw new \InvalidArgumentException('If more than one message is provided, a summary is required.'); + } + } + + /** + * Implements magic ::__get() method. + */ + public function __get(string $name): mixed { + return match ($name) { + // The messages must be private so that they cannot be mutated by external + // code, but we want to allow callers to access them in the same way as + // $this->summary and $this->severity. + 'messages' => $this->messages, + }; + } + + /** + * Creates an error ValidationResult object from a throwable. + * + * @param \Throwable $throwable + * The throwable. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + * + * @return static + */ + public static function createErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): static { + // All Composer Stager exceptions are translatable. + $is_translatable = $throwable instanceof ExceptionInterface; + $message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage(); + return new static(SystemManager::REQUIREMENT_ERROR, [$message], $summary, $is_translatable); + } + + /** + * Creates an error ValidationResult object. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + * + * @return static + */ + public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): static { + return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary, TRUE); + } + + /** + * Creates a warning ValidationResult object. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + * + * @return static + */ + public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): static { + return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary, TRUE); + } + + /** + * Returns the overall severity for a set of validation results. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * The validation results. + * + * @return int + * The overall severity of the results. Will be one of the + * SystemManager::REQUIREMENT_* constants. + */ + public static function getOverallSeverity(array $results): int { + foreach ($results as $result) { + if ($result->severity === SystemManager::REQUIREMENT_ERROR) { + return SystemManager::REQUIREMENT_ERROR; + } + } + // If there were no errors, then any remaining results must be warnings. + return $results ? SystemManager::REQUIREMENT_WARNING : SystemManager::REQUIREMENT_OK; + } + + /** + * Determines if two validation results are equivalent. + * + * @param self $a + * A validation result. + * @param self $b + * Another validation result. + * + * @return bool + * TRUE if the given validation results have the same severity, summary, + * and messages (in the same order); otherwise FALSE. + */ + public static function isEqual(self $a, self $b): bool { + return ( + $a->severity === $b->severity && + strval($a->summary) === strval($b->summary) && + array_map('strval', $a->messages) === array_map('strval', $b->messages) + ); + } + +} diff --git a/core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php b/core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php new file mode 100644 index 000000000000..e83fbb6cfb30 --- /dev/null +++ b/core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\PathLocator; + +/** + * Validates the list of packages that are allowed to scaffold files. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class AllowedScaffoldPackagesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * Validates that only the implicitly allowed packages can use scaffolding. + */ + public function validate(PreOperationStageEvent $event): void { + $stage = $event->stage; + $path = $event instanceof PreApplyEvent + ? $stage->getStageDirectory() + : $this->pathLocator->getProjectRoot(); + + // @see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold + $implicitly_allowed_packages = [ + "drupal/legacy-scaffold-assets", + "drupal/core", + ]; + $extra = Json::decode($this->composerInspector->getConfig('extra', $path . '/composer.json')); + $allowed_packages = $extra['drupal-scaffold']['allowed-packages'] ?? []; + $extra_packages = array_diff($allowed_packages, $implicitly_allowed_packages); + if (!empty($extra_packages)) { + $event->addError( + array_map($this->t(...), $extra_packages), + $this->t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href=":url">the scaffold documentation</a> for more information.', [ + ':url' => 'https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold', + ]) + ); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() : array { + return [ + StatusCheckEvent::class => 'validate', + PreCreateEvent::class => 'validate', + PreApplyEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/BaseRequirementValidatorTrait.php b/core/modules/package_manager/src/Validator/BaseRequirementValidatorTrait.php new file mode 100644 index 000000000000..e7f1020bab5c --- /dev/null +++ b/core/modules/package_manager/src/Validator/BaseRequirementValidatorTrait.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; + +/** + * Provides methods for base requirement validators. + * + * This trait should only be used by validators that check base requirements, + * which means they run before + * \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator. + * + * Validators which use this trait should NOT stop event propagation. + * + * @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + */ +trait BaseRequirementValidatorTrait { + + /** + * Validates base requirements. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event being handled. + */ + abstract public function validate(PreOperationStageEvent $event): void; + + /** + * Implements EventSubscriberInterface::getSubscribedEvents(). + */ + public static function getSubscribedEvents(): array { + // Always run before the BaseRequirementsFulfilledValidator. + // @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + $priority = BaseRequirementsFulfilledValidator::PRIORITY + 10; + + return [ + PreCreateEvent::class => ['validate', $priority], + PreRequireEvent::class => ['validate', $priority], + PreApplyEvent::class => ['validate', $priority], + StatusCheckEvent::class => ['validate', $priority], + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php new file mode 100644 index 000000000000..df0964b87670 --- /dev/null +++ b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php @@ -0,0 +1,71 @@ +<?php + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\system\SystemManager; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that base requirements do not have any errors. + * + * Base requirements are the sorts of things that must be in a good state for + * Package Manager to be usable. For example, Composer must be available and + * usable; certain paths of the file system must be writable; the current site + * cannot be part of a multisite, and so on. + * + * This validator simply stops event propagation if any of the validators before + * it have added error results. Validators that check base requirements should + * run before this validator (they can use + * \Drupal\package_manager\Validator\BaseRequirementValidatorTrait to make this + * easier). To ensure that all base requirement errors are shown to the user, no + * base requirement validator should stop event propagation itself. + * + * Base requirement validators should not depend on each other or assume that + * Composer is usable in the current environment. + * + * @see \Drupal\package_manager\Validator\BaseRequirementValidatorTrait + */ +final class BaseRequirementsFulfilledValidator implements EventSubscriberInterface { + + /** + * The priority of this validator. + * + * @see ::getSubscribedEvents() + * + * @var int + */ + public const PRIORITY = 200; + + /** + * Validates that base requirements are fulfilled. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event. + */ + public function validate(PreOperationStageEvent $event): void { + // If there are any errors from the validators which ran before this one, + // base requirements are not fulfilled. Stop any further validators from + // running. + if ($event->getResults(SystemManager::REQUIREMENT_ERROR)) { + $event->stopPropagation(); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => ['validate', self::PRIORITY], + PreRequireEvent::class => ['validate', self::PRIORITY], + PreApplyEvent::class => ['validate', self::PRIORITY], + StatusCheckEvent::class => ['validate', self::PRIORITY], + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php b/core/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php new file mode 100644 index 000000000000..9207b9dcc720 --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Composer\Semver\Semver; +use Composer\Semver\VersionParser; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that the packages to install meet the minimum stability. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ComposerMinimumStabilityValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $inspector, + ) {} + + /** + * Validates composer minimum stability. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The stage event. + */ + public function validate(PreRequireEvent $event): void { + $dir = $this->pathLocator->getProjectRoot(); + $minimum_stability = $this->inspector->getConfig('minimum-stability', $dir); + $requested_packages = array_merge($event->getDevPackages(), $event->getRuntimePackages()); + + foreach ($requested_packages as $package_name => $version) { + // In the root composer.json, a stability flag can also be specified. They + // take the form `constraint@stability`. A stability flag + // allows the project owner to deviate from the minimum-stability setting. + // @see https://getcomposer.org/doc/04-schema.md#package-links + // @see \Composer\Package\Loader\RootPackageLoader::extractStabilityFlags() + if (str_contains($version, '@')) { + continue; + } + $stability = VersionParser::parseStability($version); + + // Because drupal/core prefers to not depend on composer/composer we need + // to compare two versions that are identical except for stability to + // determine if the package stability is less that the minimum stability. + if (Semver::satisfies("1.0.0-$stability", "< 1.0.0-$minimum_stability")) { + $event->addError([ + $this->t("<code>@package_name</code>'s requested version @package_version is less stable (@package_stability) than the minimum stability (@minimum_stability) required in @file.", + [ + '@package_name' => $package_name, + '@package_version' => $version, + '@package_stability' => $stability, + '@minimum_stability' => $minimum_stability, + '@file' => $this->pathLocator->getProjectRoot() . '/composer.json', + ] + ), + ]); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreRequireEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php b/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php new file mode 100644 index 000000000000..c075b4881d1b --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Composer\Semver\Semver; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the configuration of the cweagans/composer-patches plugin. + * + * To ensure that applied patches remain consistent between the active and + * stage directories, the following rules are enforced if the patcher is + * installed: + * - It must be installed in both places, or in neither of them. It can't, for + * example, be installed in the active directory but not the stage directory + * (or vice versa). + * - It must be one of the project's direct runtime or dev dependencies. + * - It cannot be installed or removed by Package Manager. In other words, it + * must be added to the project at the command line by someone technical + * enough to install and configure it properly. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ComposerPatchesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The name of the plugin being analyzed. + * + * @var string + */ + private const PLUGIN_NAME = 'cweagans/composer-patches'; + + public function __construct( + private readonly ModuleHandlerInterface $moduleHandler, + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * Validates the status of the patcher plugin. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event object. + */ + public function validate(PreOperationStageEvent $event): void { + $messages = []; + + [$plugin_installed_in_active, $is_active_root_requirement, $active_configuration_ok] = $this->computePatcherStatus($this->pathLocator->getProjectRoot()); + if ($event instanceof PreApplyEvent) { + [$plugin_installed_in_stage, $is_stage_root_requirement, $stage_configuration_ok] = $this->computePatcherStatus($event->stage->getStageDirectory()); + $has_staged_update = TRUE; + } + else { + // No staged update exists. + $has_staged_update = FALSE; + } + + // If there's a staged update and the patcher has been installed or removed + // in the stage directory, that's a problem. + if ($has_staged_update && $plugin_installed_in_active !== $plugin_installed_in_stage) { + if ($plugin_installed_in_stage) { + $message = $this->t('It cannot be installed by Package Manager.'); + } + else { + $message = $this->t('It cannot be removed by Package Manager.'); + } + $messages[] = $this->createErrorMessage($message, 'package-manager-faq-composer-patches-installed-or-removed'); + } + + // If the patcher is not listed in the runtime or dev dependencies, that's + // an error as well. + if (($plugin_installed_in_active && !$is_active_root_requirement) || ($has_staged_update && $plugin_installed_in_stage && !$is_stage_root_requirement)) { + $messages[] = $this->createErrorMessage($this->t('It must be a root dependency.'), 'package-manager-faq-composer-patches-not-a-root-dependency'); + } + + // If the plugin is misconfigured in either the active or stage directories, + // flag an error. + if (($plugin_installed_in_active && !$active_configuration_ok) || ($has_staged_update && $plugin_installed_in_stage && !$stage_configuration_ok)) { + $messages[] = $this->t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'); + } + + if ($messages) { + $summary = $this->t("Problems detected related to the Composer plugin <code>@plugin</code>.", [ + '@plugin' => static::PLUGIN_NAME, + ]); + $event->addError($messages, $summary); + } + } + + /** + * Appends a link to online help to an error message. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup $message + * The error message. + * @param string $fragment + * The fragment of the online help to link to. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The final, translated error message. + */ + private function createErrorMessage(TranslatableMarkup $message, string $fragment): TranslatableMarkup { + if ($this->moduleHandler->moduleExists('help')) { + $url = Url::fromRoute('help.page', ['name' => 'package_manager']) + ->setOption('fragment', $fragment) + ->toString(); + + return $this->t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', [ + '@message' => $message, + ':url' => $url, + ]); + } + return $message; + } + + /** + * Computes the status of the patcher plugin in a particular directory. + * + * @param string $working_dir + * The directory in which to run Composer. + * + * @return bool[] + * An indexed array containing three booleans, in order: + * - Whether the patcher plugin is installed. + * - Whether the patcher plugin is a root requirement in composer.json (in + * either the runtime or dev dependencies). + * - Whether the `composer-exit-on-patch-failure` flag is set in the `extra` + * section of composer.json. + */ + private function computePatcherStatus(string $working_dir): array { + $list = $this->composerInspector->getInstalledPackagesList($working_dir); + $installed_version = $list[static::PLUGIN_NAME]?->version; + + $info = $this->composerInspector->getRootPackageInfo($working_dir); + $is_root_requirement = array_key_exists(static::PLUGIN_NAME, $info['requires'] ?? []) || array_key_exists(static::PLUGIN_NAME, $info['devRequires'] ?? []); + + // The 2.x version of the plugin always exits with an error if a patch can't + // be applied. + if ($installed_version && Semver::satisfies($installed_version, '^2')) { + $exit_on_failure = TRUE; + } + else { + $extra = Json::decode($this->composerInspector->getConfig('extra', $working_dir)); + $exit_on_failure = $extra['composer-exit-on-patch-failure'] ?? FALSE; + } + + return [ + is_string($installed_version), + $is_root_requirement, + $exit_on_failure, + ]; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'validate', + PreApplyEvent::class => 'validate', + StatusCheckEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php b/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php new file mode 100644 index 000000000000..913ed20c1549 --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Composer\Semver\Semver; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Exception\RuntimeException; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the allowed Composer plugins, both in active and stage. + * + * Composer plugins can make far-reaching changes on the filesystem. That is why + * they can cause Package Manager (more specifically the infrastructure it uses: + * php-tuf/composer-stager) to not work reliably; potentially even break a site! + * + * This validator restricts the use of Composer plugins: + * - Allowing all plugins to run indiscriminately is discouraged by Composer, + * but disallowed by this module (it is too risky): + * `config.allowed-plugins = true` is forbidden. + * - Installed Composer plugins that are not allowed (in composer.json's + * `config.allowed-plugins ) are not executed by Composer, so + * these are safe. + * - Installed Composer plugins that are allowed need to be either explicitly + * supported by this validator (they may still need their own validation to + * ensure their configuration is safe, for example Drupal core's vendor + * hardening plugin), or explicitly trusted by adding it to the + * `package_manager.settings` configuration's + * `additional_trusted_composer_plugins` list. + * + * @todo Determine how other Composer plugins will be supported in + * https://drupal.org/i/3339417. + * + * @see https://getcomposer.org/doc/04-schema.md#type + * @see https://getcomposer.org/doc/articles/plugins.md + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ComposerPluginsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Composer plugins known to modify other packages, but are validated. + * + * The validation guarantees they are safe to use. + * + * @var string[] + * Keys are Composer plugin package names, values are version constraints + * for those plugins that this validator explicitly supports. + */ + private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [ + // @see \Drupal\package_manager\Validator\ComposerPatchesValidator + 'cweagans/composer-patches' => '^1.7.3 || ^2', + // @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder + 'drupal/core-vendor-hardening' => '*', + 'php-http/discovery' => '*', + ]; + + /** + * Composer plugins known to NOT modify other packages. + * + * @var string[] + * Keys are Composer plugin package names, values are version constraints + * for those plugins that this validator explicitly supports. + */ + private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [ + 'composer/installers' => '^2.0', + 'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0', + 'drupal/core-composer-scaffold' => '*', + 'drupal/core-project-message' => '*', + 'phpstan/extension-installer' => '^1.1', + PhpTufValidator::PLUGIN_NAME => '^1', + ]; + + /** + * The additional trusted Composer plugin package names. + * + * The package names are normalized. + * + * @var string[] + * Keys are package names, values are version constraints. + */ + private array $additionalTrustedComposerPlugins; + + public function __construct( + ConfigFactoryInterface $config_factory, + private readonly ComposerInspector $inspector, + private readonly PathLocator $pathLocator, + ) { + $settings = $config_factory->get('package_manager.settings'); + $this->additionalTrustedComposerPlugins = array_fill_keys( + array_map( + [__CLASS__, 'normalizePackageName'], + $settings->get('additional_trusted_composer_plugins') + ), + // The additional_trusted_composer_plugins setting cannot specify a + // version constraint. The plugins are either trusted or they're not. + '*' + ); + } + + /** + * Normalizes a package name. + * + * @param string $package_name + * A package name. + * + * @return string + * The normalized package name. + */ + private static function normalizePackageName(string $package_name): string { + return strtolower($package_name); + } + + /** + * Validates the allowed Composer plugins, both in active and stage. + */ + public function validate(PreOperationStageEvent $event): void { + $stage = $event->stage; + + // When about to copy the changes from the stage directory to the active + // directory, use the stage directory's composer instead of the active. + // Because composer plugins may be added or removed; the only thing that + // matters is the set of composer plugins that *will* apply — if a composer + // plugin is being removed, that's fine. + $dir = $event instanceof PreApplyEvent + ? $stage->getStageDirectory() + : $this->pathLocator->getProjectRoot(); + try { + $allowed_plugins = $this->inspector->getAllowPluginsConfig($dir); + } + catch (RuntimeException $exception) { + $event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.')); + return; + } + + if ($allowed_plugins === TRUE) { + $event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]); + return; + } + + // TRICKY: additional trusted Composer plugins is listed first, to allow + // site owners who know what they're doing to use unsupported versions of + // supported Composer plugins. + $trusted_plugins = $this->additionalTrustedComposerPlugins + + self::SUPPORTED_PLUGINS_THAT_DO_MODIFY + + self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY; + + assert(is_array($allowed_plugins)); + // Only packages with `true` as a value are actually executed by Composer. + $allowed_plugins = array_keys(array_filter($allowed_plugins)); + // The keys are normalized package names, and the values are the original, + // non-normalized package names. + $allowed_plugins = array_combine( + array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins), + $allowed_plugins + ); + + $installed_packages = $this->inspector->getInstalledPackagesList($dir); + // Determine which plugins are both trusted by us, AND allowed by Composer's + // configuration. + $supported_plugins = array_intersect_key($allowed_plugins, $trusted_plugins); + // Create an array whose keys are the names of those plugins, and the values + // are their installed versions. + $supported_plugins_installed_versions = array_combine( + $supported_plugins, + array_map( + fn (string $name): ?string => $installed_packages[$name]?->version, + $supported_plugins + ) + ); + // Find the plugins whose installed versions aren't in the supported range. + $unsupported_installed_versions = array_filter( + $supported_plugins_installed_versions, + fn (?string $version, string $name): bool => $version && !Semver::satisfies($version, $trusted_plugins[$name]), + ARRAY_FILTER_USE_BOTH + ); + + $untrusted_plugins = array_diff_key($allowed_plugins, $trusted_plugins); + + $messages = array_map( + fn (string $raw_name) => $this->t('<code>@name</code>', ['@name' => $raw_name]), + $untrusted_plugins + ); + foreach ($unsupported_installed_versions as $name => $installed_version) { + $messages[] = $this->t("<code>@name</code> is supported, but only version <code>@supported_version</code>, found <code>@installed_version</code>.", [ + '@name' => $name, + '@supported_version' => $trusted_plugins[$name], + '@installed_version' => $installed_version, + ]); + } + + if ($messages) { + $summary = $this->formatPlural( + count($messages), + 'An unsupported Composer plugin was detected.', + 'Unsupported Composer plugins were detected.', + ); + $event->addError($messages, $summary); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'validate', + PreApplyEvent::class => 'validate', + StatusCheckEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/ComposerValidator.php b/core/modules/package_manager/src/Validator/ComposerValidator.php new file mode 100644 index 000000000000..97ef0a1d720f --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerValidator.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the project can be used by the Composer Inspector. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ComposerValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + use StringTranslationTrait; + + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + private readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Validates that the Composer executable is the correct version. + */ + public function validate(PreOperationStageEvent $event): void { + // If we can't stat processes, there's nothing else we can possibly do here. + // @see \Symfony\Component\Process\Process::__construct() + if (!\function_exists('proc_open')) { + $message = $this->t('Composer cannot be used because the <code>proc_open()</code> function is disabled.'); + if ($this->moduleHandler->moduleExists('help')) { + $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [ + '@message' => $message, + ':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'), + ]); + } + $event->addError([$message]); + return; + } + + $messages = []; + $dir = $event instanceof PreApplyEvent + ? $event->stage->getStageDirectory() + : $this->pathLocator->getProjectRoot(); + try { + $this->composerInspector->validate($dir); + } + catch (\Throwable $e) { + if ($this->moduleHandler->moduleExists('help')) { + $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [ + '@message' => $e->getMessage(), + ':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'), + ]); + $event->addError([$message]); + } + else { + $event->addErrorFromThrowable($e); + } + return; + } + + $settings = []; + foreach (['disable-tls', 'secure-http'] as $key) { + try { + $settings[$key] = json_decode($this->composerInspector->getConfig($key, $dir)); + } + catch (\Throwable $e) { + $event->addErrorFromThrowable($e, $this->t('Unable to determine Composer <code>@key</code> setting.', [ + '@key' => $key, + ])); + return; + } + } + + // If disable-tls is enabled, it overrides secure-http and sets its value to + // FALSE, even if secure-http is set to TRUE explicitly. + if ($settings['disable-tls'] === TRUE) { + $message = $this->t('TLS must be enabled for HTTPS Composer downloads.'); + + // If the Help module is installed, link to our help page, which displays + // the commands for configuring Composer correctly. Otherwise, direct + // users straight to the Composer documentation, which is a little less + // helpful. + if ($this->moduleHandler->moduleExists('help')) { + $messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [ + '@message' => $message, + ':url' => self::getHelpUrl('package-manager-requirements'), + ]); + } + else { + $messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [ + '@message' => $message, + ':url' => 'https://getcomposer.org/doc/06-config.md#disable-tls', + ]); + } + $messages[] = $this->t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.'); + } + elseif ($settings['secure-http'] !== TRUE) { + $message = $this->t('HTTPS must be enabled for Composer downloads.'); + + if ($this->moduleHandler->moduleExists('help')) { + $messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [ + '@message' => $message, + ':url' => self::getHelpUrl('package-manager-requirements'), + ]); + } + else { + $messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [ + '@message' => $message, + ':url' => 'https://getcomposer.org/doc/06-config.md#secure-http', + ]); + } + } + + if ($messages) { + $event->addError($messages, $this->t("Composer settings don't satisfy Package Manager's requirements.")); + } + } + + /** + * Returns a URL to a specific fragment of Package Manager's online help. + * + * @param string $fragment + * The fragment to link to. + * + * @return string + * A URL to Package Manager's online help. + */ + private static function getHelpUrl(string $fragment): string { + return Url::fromRoute('help.page', ['name' => 'package_manager']) + ->setOption('fragment', $fragment) + ->toString(); + } + +} diff --git a/core/modules/package_manager/src/Validator/DiskSpaceValidator.php b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php new file mode 100644 index 000000000000..ec875b3b2aba --- /dev/null +++ b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\Bytes; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that there is enough free disk space to do stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class DiskSpaceValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + use StringTranslationTrait; + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Wrapper around the disk_free_space() function. + * + * @param string $path + * The path for which to retrieve the amount of free disk space. + * + * @return float + * The number of bytes of free space on the disk. + * + * @throws \RuntimeException + * If the amount of free space could not be determined. + */ + protected function freeSpace(string $path): float { + $free_space = disk_free_space($path); + if ($free_space === FALSE) { + throw new \RuntimeException("Cannot get disk information for $path."); + } + return $free_space; + } + + /** + * Wrapper around the stat() function. + * + * @param string $path + * The path to check. + * + * @return mixed[] + * The statistics for the path. + * + * @throws \RuntimeException + * If the statistics could not be determined. + */ + protected function stat(string $path): array { + $stat = stat($path); + if ($stat === FALSE) { + throw new \RuntimeException("Cannot get information for $path."); + } + return $stat; + } + + /** + * Checks if two paths are located on the same logical disk. + * + * @param string $root + * The path of the project root. + * @param string $vendor + * The path of the vendor directory. + * + * @return bool + * TRUE if the project root and vendor directory are on the same logical + * disk, FALSE otherwise. + */ + protected function areSameLogicalDisk(string $root, string $vendor): bool { + $root_statistics = $this->stat($root); + $vendor_statistics = $this->stat($vendor); + return $root_statistics['dev'] === $vendor_statistics['dev']; + } + + /** + * Validates that there is enough free disk space to do stage operations. + */ + public function validate(PreOperationStageEvent $event): void { + $root_path = $this->pathLocator->getProjectRoot(); + $vendor_path = $this->pathLocator->getVendorDirectory(); + $messages = []; + + // @todo Make this configurable or set to a different value in + // https://www.drupal.org/i/3166416. + $minimum_mb = 1024; + $minimum_bytes = Bytes::toNumber($minimum_mb . 'M'); + + if (!$this->areSameLogicalDisk($root_path, $vendor_path)) { + if ($this->freeSpace($root_path) < $minimum_bytes) { + $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $root_path, + '@space' => $minimum_mb, + ]); + } + if (is_dir($vendor_path) && $this->freeSpace($vendor_path) < $minimum_bytes) { + $messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [ + '@vendor' => $vendor_path, + '@space' => $minimum_mb, + ]); + } + } + elseif ($this->freeSpace($root_path) < $minimum_bytes) { + $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $root_path, + '@space' => $minimum_mb, + ]); + } + $temp = $this->temporaryDirectory(); + if ($this->freeSpace($temp) < $minimum_bytes) { + $messages[] = $this->t('Directory "@temp" has insufficient space. There must be at least @space megabytes free.', [ + '@temp' => $temp, + '@space' => $minimum_mb, + ]); + } + + if ($messages) { + $summary = count($messages) > 1 + ? $this->t("There is not enough disk space to create a stage directory.") + : NULL; + $event->addError($messages, $summary); + } + } + + /** + * Returns the path of the system temporary directory. + * + * @return string + * The absolute path of the system temporary directory. + */ + protected function temporaryDirectory(): string { + return FileSystem::getOsTemporaryDirectory(); + } + +} diff --git a/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php b/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php new file mode 100644 index 000000000000..b7bc041d7790 --- /dev/null +++ b/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Extension\ExtensionDiscovery; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the stage does not have duplicate info.yml not present in active. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class DuplicateInfoFileValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Validates the stage does not have duplicate info.yml not present in active. + */ + public function validate(PreApplyEvent $event): void { + $active_dir = $this->pathLocator->getProjectRoot(); + $stage_dir = $event->stage->getStageDirectory(); + $active_info_files = $this->findInfoFiles($active_dir); + $stage_info_files = $this->findInfoFiles($stage_dir); + + foreach ($stage_info_files as $stage_info_file => $stage_info_count) { + if (isset($active_info_files[$stage_info_file])) { + // Check if stage directory has more info.yml files matching + // $stage_info_file than in the active directory. + if ($stage_info_count > $active_info_files[$stage_info_file]) { + $event->addError([ + $this->t('The stage directory has @stage_count instances of @stage_info_file as compared to @active_count in the active directory. This likely indicates that a duplicate extension was installed.', [ + '@stage_info_file' => $stage_info_file, + '@stage_count' => $stage_info_count, + '@active_count' => $active_info_files[$stage_info_file], + ]), + ]); + } + } + // Check if stage directory has two or more info.yml files matching + // $stage_info_file which are not in active directory. + elseif ($stage_info_count > 1) { + $event->addError([ + $this->t('The stage directory has @stage_count instances of @stage_info_file. This likely indicates that a duplicate extension was installed.', [ + '@stage_info_file' => $stage_info_file, + '@stage_count' => $stage_info_count, + ]), + ]); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'validate', + ]; + } + + /** + * Recursively finds info.yml files in a directory. + * + * @param string $dir + * The path of the directory to check. + * + * @return int[] + * Array of count of info.yml files in the directory keyed by file name. + */ + private function findInfoFiles(string $dir): array { + // Use the official extension discovery mechanism, but tweak it, because by + // default it resolves duplicates. + // @see \Drupal\Core\Extension\ExtensionDiscovery::process() + $duplicate_aware_extension_discovery = new class($dir, FALSE, []) extends ExtensionDiscovery { + + /** + * {@inheritdoc} + */ + protected function process(array $all_files) { + // Unlike parent implementation: no processing, to retain duplicates. + return $all_files; + } + + }; + + // Scan all 4 extension types, explicitly ignoring tests. + $extension_info_files = array_merge( + array_keys($duplicate_aware_extension_discovery->scan('module', FALSE)), + array_keys($duplicate_aware_extension_discovery->scan('theme', FALSE)), + array_keys($duplicate_aware_extension_discovery->scan('profile', FALSE)), + array_keys($duplicate_aware_extension_discovery->scan('theme_engine', FALSE)), + ); + + $info_files = []; + foreach ($extension_info_files as $info_file) { + $file_name = basename($info_file); + $info_files[$file_name] = ($info_files[$file_name] ?? 0) + 1; + } + return $info_files; + } + +} diff --git a/core/modules/package_manager/src/Validator/EnabledExtensionsValidator.php b/core/modules/package_manager/src/Validator/EnabledExtensionsValidator.php new file mode 100644 index 000000000000..526f5a0fecb0 --- /dev/null +++ b/core/modules/package_manager/src/Validator/EnabledExtensionsValidator.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\ThemeHandlerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates no enabled Drupal extensions are removed from the stage directory. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class EnabledExtensionsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ModuleHandlerInterface $moduleHandler, + private readonly ComposerInspector $composerInspector, + private readonly ThemeHandlerInterface $themeHandler, + ) {} + + /** + * Validates that no enabled Drupal extensions have been removed. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function validate(PreApplyEvent $event): void { + $active_packages_list = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $stage_packages_list = $this->composerInspector->getInstalledPackagesList($event->stage->getStageDirectory()); + + $extensions_list = $this->moduleHandler->getModuleList() + $this->themeHandler->listInfo(); + foreach ($extensions_list as $extension) { + $extension_name = $extension->getName(); + $package = $active_packages_list->getPackageByDrupalProjectName($extension_name); + if ($package && $stage_packages_list->getPackageByDrupalProjectName($extension_name) === NULL) { + $removed_project_messages[] = t("'@name' @type (provided by <code>@package</code>)", [ + '@name' => $extension_name, + '@type' => $extension->getType(), + '@package' => $package->name, + ]); + } + } + + if (!empty($removed_project_messages)) { + $removed_packages_summary = $this->formatPlural( + count($removed_project_messages), + 'The update cannot proceed because the following enabled Drupal extension was removed during the update.', + 'The update cannot proceed because the following enabled Drupal extensions were removed during the update.' + ); + $event->addError($removed_project_messages, $removed_packages_summary); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/EnvironmentSupportValidator.php b/core/modules/package_manager/src/Validator/EnvironmentSupportValidator.php new file mode 100644 index 000000000000..9837b53ce918 --- /dev/null +++ b/core/modules/package_manager/src/Validator/EnvironmentSupportValidator.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\Url; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that the environment has support for Package Manager. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class EnvironmentSupportValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait { + getSubscribedEvents as private getSubscribedEventsFromTrait; + } + use StringTranslationTrait; + + /** + * The name of the environment variable to check. + * + * This environment variable, if defined, should be parseable by + * \Drupal\Core\Url::fromUri() and link to an explanation of why Package + * Manager is not supported in the current environment. + * + * @var string + */ + public const VARIABLE_NAME = 'DRUPAL_PACKAGE_MANAGER_NOT_SUPPORTED_HELP_URL'; + + /** + * Checks that this environment supports Package Manager. + */ + public function validate(PreOperationStageEvent $event): void { + $message = $this->t('Package Manager is not supported by your environment.'); + + $help_url = getenv(static::VARIABLE_NAME); + if (empty($help_url)) { + return; + } + // If the URL is not parseable, catch the exception that Url::fromUri() + // would generate. + try { + $message = $this->t('<a href=":url">@message</a>', [ + ':url' => Url::fromUri($help_url)->toString(), + '@message' => $message, + ]); + } + catch (\InvalidArgumentException) { + // No need to do anything here. The message just won't be a link. + } + $event->addError([$message]); + // If Package Manager is unsupported, there's no point in doing any more + // validation. + $event->stopPropagation(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // Set priority to run before BaseRequirementsFulfilledValidator, and even + // before other base requirement validators. + // @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + return array_map(fn () => ['validate', BaseRequirementsFulfilledValidator::PRIORITY + 1000], static::getSubscribedEventsFromTrait()); + } + +} diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php new file mode 100644 index 000000000000..c0ab1398bcbb --- /dev/null +++ b/core/modules/package_manager/src/Validator/LockFileValidator.php @@ -0,0 +1,185 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\Core\KeyValueStore\KeyValueStoreInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that the active lock file is unchanged during stage operations. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class LockFileValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The key under which to store the hash of the active lock file. + * + * @var string + */ + private const KEY = 'lock_hash'; + + /** + * The key-value store. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface + */ + private readonly KeyValueStoreInterface $keyValue; + + public function __construct( + KeyValueFactoryInterface $keyValueFactory, + private readonly PathLocator $pathLocator, + ) { + $this->keyValue = $keyValueFactory->get('package_manager'); + } + + /** + * Returns the XXH64 hash of a file. + * + * This method is a thin wrapper around hash_file() to facilitate testing. On + * failure, hash_file() emits a warning but doesn't throw an exception. In + * tests, however, PHPUnit converts warnings to exceptions, so we need to + * catch those and convert them to the value hash_file() will actually return + * on error, which is FALSE. We could also just call `hash_file` directly and + * use @ to suppress warnings, but those would be unclear and likely to be + * accidentally removed later. + * + * @param string $path + * Path of the file to hash. + * + * @return string|false + * The hash of the given file, or FALSE if the file doesn't exist or cannot + * be hashed. + */ + private function getHash(string $path): string|false { + try { + return @hash_file('xxh64', $path); + } + catch (\Throwable) { + return FALSE; + } + } + + /** + * Stores the XXH64 hash of the active lock file. + * + * We store the hash of the lock file itself, rather than its content-hash + * value, which is actually a hash of certain parts of composer.json. Our aim + * is to verify that the actual installed packages have not changed + * unexpectedly; we don't care about the contents of composer.json. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event being handled. + */ + public function storeHash(PreCreateEvent $event): void { + $active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock'; + $hash = $this->getHash($active_lock_file_path); + if ($hash) { + $this->keyValue->set(static::KEY, $hash); + } + else { + $event->addError([ + $this->t('The active lock file (@file) does not exist.', [ + '@file' => $active_lock_file_path, + ]), + ]); + } + } + + /** + * Checks that the active lock file is unchanged during stage operations. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event being handled. + */ + public function validate(PreOperationStageEvent $event): void { + $stage = $event->stage; + + // Early return if the stage is not already created. + if ($event instanceof StatusCheckEvent && $stage->isAvailable()) { + return; + } + + $messages = []; + // Ensure we can get a current hash of the lock file. + $active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock'; + $active_lock_file_hash = $this->getHash($active_lock_file_path); + if (empty($active_lock_file_hash)) { + $messages[] = $this->t('The active lock file (@file) does not exist.', [ + '@file' => $active_lock_file_path, + ]); + } + + // Ensure we also have a stored hash of the lock file. + $active_lock_file_stored_hash = $this->keyValue->get(static::KEY); + if (empty($active_lock_file_stored_hash)) { + throw new \LogicException('Stored hash key deleted.'); + } + + // If we have both hashes, ensure they match. + if ($active_lock_file_hash && !hash_equals($active_lock_file_stored_hash, $active_lock_file_hash)) { + $messages[] = $this->t('Unexpected changes were detected in the active lock file (@file), which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.', [ + '@file' => $active_lock_file_path, + ]); + } + + // Don't allow staged changes to be applied if the staged lock file has no + // apparent changes. + if (empty($messages) && $event instanceof PreApplyEvent) { + $staged_lock_file_path = $stage->getStageDirectory() . DIRECTORY_SEPARATOR . 'composer.lock'; + $staged_lock_file_hash = $this->getHash($staged_lock_file_path); + if ($staged_lock_file_hash && hash_equals($active_lock_file_hash, $staged_lock_file_hash)) { + $messages[] = $this->t('There appear to be no pending Composer operations because the active lock file (@active_file) and the staged lock file (@staged_file) are identical.', [ + '@active_file' => $active_lock_file_path, + '@staged_file' => $staged_lock_file_path, + ]); + } + } + + if (!empty($messages)) { + $summary = $this->formatPlural( + count($messages), + 'Problem detected in lock file during stage operations.', + 'Problems detected in lock file during stage operations.', + ); + $event->addError($messages, $summary); + } + } + + /** + * Deletes the stored lock file hash. + */ + public function deleteHash(): void { + $this->keyValue->delete(static::KEY); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'storeHash', + PreRequireEvent::class => 'validate', + PreApplyEvent::class => 'validate', + StatusCheckEvent::class => 'validate', + PostApplyEvent::class => 'deleteHash', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/MultisiteValidator.php b/core/modules/package_manager/src/Validator/MultisiteValidator.php new file mode 100644 index 000000000000..78fee5cab398 --- /dev/null +++ b/core/modules/package_manager/src/Validator/MultisiteValidator.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that the current site is not part of a multisite. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class MultisiteValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + use StringTranslationTrait; + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Validates that the current site is not part of a multisite. + */ + public function validate(PreOperationStageEvent $event): void { + if ($this->isMultisite()) { + $event->addError([ + $this->t('Drupal multisite is not supported by Package Manager.'), + ]); + } + } + + /** + * Detects if the current site is part of a multisite. + * + * @return bool + * TRUE if the current site is part of a multisite, otherwise FALSE. + */ + private function isMultisite(): bool { + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $web_root .= '/'; + } + $sites_php_path = $this->pathLocator->getProjectRoot() . '/' . $web_root . 'sites/sites.php'; + + if (!file_exists($sites_php_path)) { + return FALSE; + } + + // @see \Drupal\Core\DrupalKernel::findSitePath() + $sites = []; + include $sites_php_path; + // @see example.sites.php + return count(array_unique($sites)) > 1; + } + +} diff --git a/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php b/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php new file mode 100644 index 000000000000..d0a6cf1e367e --- /dev/null +++ b/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that newly installed packages don't overwrite existing directories. + * + * Whether a new package in the stage directory would overwrite an existing + * directory in the active directory when the operation is applied is determined + * by inspecting the `path` property of the staged package. + * + * Certain packages, such as those with the `metapackage` type, don't have a + * `path` property and are ignored by this validator. The Composer facade at + * https://packages.drupal.org/8 currently uses the `metapackage` type for + * submodules of Drupal projects. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + * + * @see https://getcomposer.org/doc/04-schema.md#type + */ +final class OverwriteExistingPackagesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + ) {} + + /** + * Validates that new installed packages don't overwrite existing directories. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event being handled. + */ + public function validate(PreApplyEvent $event): void { + $active_dir = $this->pathLocator->getProjectRoot(); + $stage_dir = $event->stage->getStageDirectory(); + $active_packages = $this->composerInspector->getInstalledPackagesList($active_dir); + $new_packages = $this->composerInspector->getInstalledPackagesList($stage_dir) + ->getPackagesNotIn($active_packages); + + foreach ($new_packages as $package) { + if (empty($package->path)) { + // Packages without a `path` cannot overwrite existing directories. + continue; + } + $relative_path = str_replace($stage_dir, '', $package->path); + if (is_dir($active_dir . DIRECTORY_SEPARATOR . $relative_path)) { + $event->addError([ + $this->t('The new package @package will be installed in the directory @path, which already exists but is not managed by Composer.', [ + '@package' => $package->name, + '@path' => $relative_path, + ]), + ]); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php new file mode 100644 index 000000000000..7ad12003d2d4 --- /dev/null +++ b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Update\UpdateRegistry; +use Drupal\Core\Url; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that there are no pending database updates. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PendingUpdatesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly string $appRoot, + private readonly UpdateRegistry $updateRegistry, + ) {} + + /** + * Validates that there are no pending database updates. + */ + public function validate(PreOperationStageEvent $event): void { + if ($this->updatesExist()) { + $message = $this->t('Some modules have database updates pending. You should run the <a href=":update">database update script</a> immediately.', [ + ':update' => Url::fromRoute('system.db_update')->toString(), + ]); + $event->addError([$message]); + } + } + + /** + * Checks if there are any pending update or post-update hooks. + * + * @return bool + * TRUE if there are any pending update or post-update hooks, FALSE + * otherwise. + */ + public function updatesExist(): bool { + require_once $this->appRoot . '/core/includes/install.inc'; + require_once $this->appRoot . '/core/includes/update.inc'; + + drupal_load_updates(); + $hook_updates = update_get_update_list(); + $post_updates = $this->updateRegistry->getPendingUpdateFunctions(); + + return $hook_updates || $post_updates; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'validate', + StatusCheckEvent::class => 'validate', + PreApplyEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/PhpExtensionsValidator.php b/core/modules/package_manager/src/Validator/PhpExtensionsValidator.php new file mode 100644 index 000000000000..8933ffbd2cf7 --- /dev/null +++ b/core/modules/package_manager/src/Validator/PhpExtensionsValidator.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Performs validation if certain PHP extensions are enabled. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class PhpExtensionsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Indicates if a particular PHP extension is loaded. + * + * @param string $name + * The name of the PHP extension to check for. + * + * @return bool + * TRUE if the given extension is loaded, FALSE otherwise. + */ + final protected function isExtensionLoaded(string $name): bool { + // If and ONLY if we're currently running a test, allow the list of loaded + // extensions to be overridden by a state variable. + if (self::insideTest()) { + // By default, assume OpenSSL is enabled and Xdebug isn't. This allows us + // to run tests in environments that we might not support in production, + // such as Drupal CI. + $loaded_extensions = \Drupal::state() + ->get('package_manager_loaded_php_extensions', ['openssl']); + return in_array($name, $loaded_extensions, TRUE); + } + return extension_loaded($name); + } + + /** + * Flags a warning if Xdebug is enabled. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent $event + * The event object. + */ + public function validateXdebug(StatusCheckEvent $event): void { + if ($this->isExtensionLoaded('xdebug')) { + $event->addWarning([ + $this->t('Xdebug is enabled, which may have a negative performance impact on Package Manager and any modules that use it.'), + ]); + } + } + + /** + * Flags an error if the OpenSSL extension is not installed. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event object. + */ + public function validateOpenSsl(PreOperationStageEvent $event): void { + if (!$this->isExtensionLoaded('openssl')) { + $message = $this->t('The OpenSSL extension is not enabled, which is a security risk. See <a href=":url">the PHP documentation</a> for information on how to enable this extension.', [ + ':url' => 'https://www.php.net/manual/en/openssl.installation.php', + ]); + $event->addError([$message]); + } + } + + /** + * Whether this validator is running inside a test. + * + * @return bool + */ + private static function insideTest(): bool { + // @see \Drupal\Core\CoreServiceProvider::registerTest() + $in_functional_test = drupal_valid_test_ua(); + // @see \Drupal\Core\DependencyInjection\DependencySerializationTrait::__wakeup() + $in_kernel_test = isset($GLOBALS['__PHPUNIT_BOOTSTRAP']); + // @see \Drupal\BuildTests\Framework\BuildTestBase::setUp() + $in_build_test = str_contains(__FILE__, DrupalFilesystem::getOsTemporaryDirectory() . '/build_workspace_'); + return $in_functional_test || $in_kernel_test || $in_build_test; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => [ + ['validateXdebug'], + ['validateOpenSsl'], + ], + PreCreateEvent::class => ['validateOpenSsl'], + PreApplyEvent::class => ['validateOpenSsl'], + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/PhpTufValidator.php b/core/modules/package_manager/src/Validator/PhpTufValidator.php new file mode 100644 index 000000000000..1e08ec16e684 --- /dev/null +++ b/core/modules/package_manager/src/Validator/PhpTufValidator.php @@ -0,0 +1,194 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Component\Assertion\Inspector; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Site\Settings; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that PHP-TUF is installed and correctly configured. + * + * In both the active and stage directories, this checks for the following + * conditions: + * - The PHP-TUF plugin is installed. + * - The plugin is not explicitly blocked by Composer's `allow-plugins` + * configuration. + * - Composer is aware of at least one repository that has TUF support + * explicitly enabled. + * + * Until it's more real world-tested, TUF protection is bypassed by default. + * Ultimately, though, Package Manager will not treat TUF as optional. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PhpTufValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The name of the PHP-TUF Composer integration plugin. + * + * @var string + */ + public const PLUGIN_NAME = 'php-tuf/composer-integration'; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + private readonly ModuleHandlerInterface $moduleHandler, + private readonly Settings $settings, + private readonly array $repositories, + ) { + assert(Inspector::assertAllStrings($repositories)); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'validate', + PreCreateEvent::class => 'validate', + PreRequireEvent::class => 'validate', + PreApplyEvent::class => 'validate', + ]; + } + + /** + * Reacts to a stage event by validating PHP-TUF configuration as needed. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event object. + */ + public function validate(PreOperationStageEvent $event): void { + $messages = $this->validateTuf($this->pathLocator->getProjectRoot()); + if ($messages) { + $event->addError($messages, $this->t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + } + + $stage = $event->stage; + if ($stage->stageDirectoryExists()) { + $messages = $this->validateTuf($stage->getStageDirectory()); + if ($messages) { + $event->addError($messages, $this->t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + } + } + } + + /** + * Flags messages if PHP-TUF is not installed and configured properly. + * + * @param string $dir + * The directory to examine. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + private function validateTuf(string $dir): array { + $messages = []; + + // This setting will be removed without warning when no longer need. + if ($this->settings->get('package_manager_bypass_tuf', TRUE)) { + return $messages; + } + + if ($this->moduleHandler->moduleExists('help')) { + $help_url = Url::fromRoute('help.page', ['name' => 'package_manager']) + ->setOption('fragment', 'package-manager-tuf-info') + ->toString(); + } + + // The Composer plugin must be installed. + $installed_packages = $this->composerInspector->getInstalledPackagesList($dir); + if (!isset($installed_packages[static::PLUGIN_NAME])) { + $message = $this->t('The <code>@plugin</code> plugin is not installed.', [ + '@plugin' => static::PLUGIN_NAME, + ]); + if (isset($help_url)) { + $message = $this->t('@message See <a href=":url">the help page</a> for more information on how to install the plugin.', [ + '@message' => $message, + ':url' => $help_url, + ]); + } + $messages[] = $message; + } + + // And it has to be explicitly enabled. + $allowed_plugins = $this->composerInspector->getAllowPluginsConfig($dir); + if ($allowed_plugins !== TRUE && empty($allowed_plugins[static::PLUGIN_NAME])) { + $message = $this->t('The <code>@plugin</code> plugin is not listed as an allowed plugin.', [ + '@plugin' => static::PLUGIN_NAME, + ]); + if (isset($help_url)) { + $message = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure the plugin.', [ + '@message' => $message, + ':url' => $help_url, + ]); + } + $messages[] = $message; + } + + // Confirm that all repositories we're configured to look at have opted into + // TUF protection. + foreach ($this->getRepositoryStatus($dir) as $url => $is_protected) { + if ($is_protected) { + continue; + } + $message = $this->t('TUF is not enabled for the <code>@url</code> repository.', [ + '@url' => $url, + ]); + if (isset($help_url)) { + $message = $this->t('@message See <a href=":url">the help page</a> for more information on how to set up this repository.', [ + '@message' => $message, + ':url' => $help_url, + ]); + } + $messages[] = $message; + } + return $messages; + } + + /** + * Gets the TUF protection status of Composer repositories. + * + * @param string $dir + * The directory in which to run Composer. + * + * @return bool[] + * An array of booleans, keyed by repository URL, indicating whether TUF + * protection is enabled for that repository. + */ + private function getRepositoryStatus(string $dir): array { + $status = []; + + $repositories = $this->composerInspector->getConfig('repositories', $dir); + $repositories = Json::decode($repositories); + + foreach ($repositories as $repository) { + // Only Composer repositories can have TUF protection. + if ($repository['type'] === 'composer') { + $url = $repository['url']; + $status[$url] = !empty($repository['tuf']); + } + } + return array_intersect_key($status, array_flip($this->repositories)); + } + +} diff --git a/core/modules/package_manager/src/Validator/RsyncValidator.php b/core/modules/package_manager/src/Validator/RsyncValidator.php new file mode 100644 index 000000000000..6f396ea47e07 --- /dev/null +++ b/core/modules/package_manager/src/Validator/RsyncValidator.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use PhpTuf\ComposerStager\API\Exception\LogicException; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that rsync is available. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class RsyncValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly ExecutableFinderInterface $executableFinder, + private readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Checks that rsync is available. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event being handled. + */ + public function validate(PreOperationStageEvent $event): void { + try { + $this->executableFinder->find('rsync'); + $rsync_found = TRUE; + } + catch (LogicException) { + $rsync_found = FALSE; + } + + if ($rsync_found === FALSE) { + $message = $this->t('<code>rsync</code> is not available.'); + + if ($this->moduleHandler->moduleExists('help')) { + $help_url = Url::fromRoute('help.page') + ->setRouteParameter('name', 'package_manager') + ->setOption('fragment', 'package-manager-faq-rsync') + ->toString(); + + $message = $this->t('@message See the <a href=":url">Package Manager help</a> for more information on how to resolve this.', [ + '@message' => $message, + ':url' => $help_url, + ]); + } + $event->addError([$message]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'validate', + PreCreateEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/SettingsValidator.php b/core/modules/package_manager/src/Validator/SettingsValidator.php new file mode 100644 index 000000000000..f89f586a9585 --- /dev/null +++ b/core/modules/package_manager/src/Validator/SettingsValidator.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Site\Settings; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that Drupal's settings are valid for Package Manager. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class SettingsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Checks that Drupal's settings are valid for Package Manager. + */ + public function validate(PreOperationStageEvent $event): void { + if (Settings::get('update_fetch_with_http_fallback')) { + $event->addError([ + $this->t('The <code>update_fetch_with_http_fallback</code> setting must be disabled.'), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'validate', + PreApplyEvent::class => 'validate', + StatusCheckEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/StageNotInActiveValidator.php b/core/modules/package_manager/src/Validator/StageNotInActiveValidator.php new file mode 100644 index 000000000000..7a48c5ddb084 --- /dev/null +++ b/core/modules/package_manager/src/Validator/StageNotInActiveValidator.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates staging root is not a subdirectory of active. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class StageNotInActiveValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait { + getSubscribedEvents as private getSubscribedEventsFromTrait; + } + use StringTranslationTrait; + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Check if staging root is a subdirectory of active. + */ + public function validate(PreOperationStageEvent $event): void { + $project_root = $this->pathLocator->getProjectRoot(); + $staging_root = $this->pathLocator->getStagingRoot(); + if (str_starts_with($staging_root, $project_root)) { + $message = $this->t("Stage directory is a subdirectory of the active directory."); + $event->addError([$message]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events = static::getSubscribedEventsFromTrait(); + // We don't need to listen to PreApplyEvent because once the stage directory + // has been created, it's not going to be moved. + unset($events[PreApplyEvent::class]); + return $events; + } + +} diff --git a/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php new file mode 100644 index 000000000000..76b088e47807 --- /dev/null +++ b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Extension\ThemeExtensionList; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Flags a warning if there are database updates in a staged update. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class StagedDBUpdateValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ModuleExtensionList $moduleList, + private readonly ThemeExtensionList $themeList, + ) {} + + /** + * Checks that the staged update does not have changes to its install files. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent $event + * The event object. + */ + public function checkForStagedDatabaseUpdates(StatusCheckEvent $event): void { + if (!$event->stage->stageDirectoryExists()) { + return; + } + $stage_dir = $event->stage->getStageDirectory(); + $extensions_with_updates = $this->getExtensionsWithDatabaseUpdates($stage_dir); + if ($extensions_with_updates) { + $extensions_with_updates = array_map($this->t(...), $extensions_with_updates); + $event->addWarning($extensions_with_updates, $this->t('Database updates have been detected in the following extensions.')); + } + } + + /** + * Determines if a staged extension has changed update functions. + * + * @param string $stage_dir + * The path of the stage directory. + * @param \Drupal\Core\Extension\Extension $extension + * The extension to check. + * + * @return bool + * TRUE if the staged copy of the extension has changed update functions + * compared to the active copy, FALSE otherwise. + * + * @todo In https://drupal.org/i/3253828 use a more sophisticated method to + * detect changes in the staged extension. Right now, we just compare hashes + * of the .install and .post_update.php files in both copies of the given + * extension, but this will cause false positives for changes to comments, + * whitespace, or runtime code like requirements checks. It would be + * preferable to use a static analyzer to detect new or changed functions + * that are actually executed during an update. No matter what, this method + * must NEVER cause false negatives, since that could result in code which + * is incompatible with the current database schema being copied to the + * active directory. + */ + public function hasStagedUpdates(string $stage_dir, Extension $extension): bool { + $active_dir = $this->pathLocator->getProjectRoot(); + + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $active_dir .= DIRECTORY_SEPARATOR . $web_root; + $stage_dir .= DIRECTORY_SEPARATOR . $web_root; + } + + $active_functions = $this->getUpdateFunctions($active_dir, $extension); + $staged_functions = $this->getUpdateFunctions($stage_dir, $extension); + + return (bool) array_diff($staged_functions, $active_functions); + } + + /** + * Returns a list of all update functions for a module. + * + * This method only exists because the API in core that scans for available + * updates can only examine the active (running) code base, but we need to be + * able to scan the staged code base as well to compare it against the active + * one. + * + * @param string $root_dir + * The root directory of the Drupal code base. + * @param \Drupal\Core\Extension\Extension $extension + * The module to check. + * + * @return string[] + * The names of the update functions in the module's .install and + * .post_update.php files. + */ + private function getUpdateFunctions(string $root_dir, Extension $extension): array { + $name = $extension->getName(); + + $path = implode(DIRECTORY_SEPARATOR, [ + $root_dir, + $extension->getPath(), + $name, + ]); + $function_names = []; + + $patterns = [ + '.install' => '/^' . $name . '_update_[0-9]+$/i', + '.post_update.php' => '/^' . $name . '_post_update_.+$/i', + ]; + foreach ($patterns as $suffix => $pattern) { + $file = $path . $suffix; + + if (!file_exists($file)) { + continue; + } + // Parse the file and scan for named functions which match the pattern. + $code = file_get_contents($file); + $tokens = token_get_all($code); + + for ($i = 0; $i < count($tokens); $i++) { + $chunk = array_slice($tokens, $i, 3); + if ($this->tokensMatchFunctionNamePattern($chunk, $pattern)) { + $function_names[] = $chunk[2][1]; + } + } + } + return $function_names; + } + + /** + * Determines if a set of tokens contain a function name matching a pattern. + * + * @param array[] $tokens + * A set of three tokens, part of a stream returned by token_get_all(). + * @param string $pattern + * If the tokens declare a named function, a regular expression to test the + * function name against. + * + * @return bool + * TRUE if the given tokens declare a function whose name matches the given + * pattern; FALSE otherwise. + * + * @see token_get_all() + */ + private function tokensMatchFunctionNamePattern(array $tokens, string $pattern): bool { + if (count($tokens) !== 3 || !Inspector::assertAllStrictArrays($tokens)) { + return FALSE; + } + // A named function declaration will always be a T_FUNCTION (the word + // `function`), followed by T_WHITESPACE (or the code would be syntactically + // invalid), followed by a T_STRING (the function name). This will ignore + // anonymous functions, but match class methods (although class methods are + // highly unlikely to match the naming patterns of update hooks). + $names = array_map('token_name', array_column($tokens, 0)); + if ($names === ['T_FUNCTION', 'T_WHITESPACE', 'T_STRING']) { + return (bool) preg_match($pattern, $tokens[2][1]); + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'checkForStagedDatabaseUpdates', + ]; + } + + /** + * Gets extensions that have database updates in the stage directory. + * + * @param string $stage_dir + * The path of the stage directory. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The names of the extensions that have database updates. + */ + public function getExtensionsWithDatabaseUpdates(string $stage_dir): array { + $extensions_with_updates = []; + // Check all installed extensions for database updates. + $lists = [$this->moduleList, $this->themeList]; + foreach ($lists as $list) { + foreach ($list->getAllInstalledInfo() as $name => $info) { + if ($this->hasStagedUpdates($stage_dir, $list->get($name))) { + $extensions_with_updates[] = $info['name']; + } + } + } + + return $extensions_with_updates; + } + +} diff --git a/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php b/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php new file mode 100644 index 000000000000..b22690439cb5 --- /dev/null +++ b/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ProjectInfo; +use Drupal\package_manager\LegacyVersionUtility; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that updated projects are secure and supported. + * + * @internal + * This class is an internal part of the module's update handling and + * should not be used by external code. + */ +final class SupportedReleaseValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * Checks if the given version of a project is supported. + * + * Checks if the given version of the given project is in the core update + * system's list of known, secure, installable releases of that project. + * considered a supported release by verifying if the project is found in the + * core update system's list of known, secure, and installable releases. + * + * @param string $name + * The name of the project. + * @param string $semantic_version + * A semantic version number for the project. + * + * @return bool + * TRUE if the given version of the project is supported, otherwise FALSE. + * given version is not supported will return FALSE. + */ + private function isSupportedRelease(string $name, string $semantic_version): bool { + $supported_releases = (new ProjectInfo($name))->getInstallableReleases(); + if (!$supported_releases) { + return FALSE; + } + + // If this version is found in the list of installable releases, it is + // secured and supported. + if (array_key_exists($semantic_version, $supported_releases)) { + return TRUE; + } + // If the semantic version number wasn't in the list of + // installable releases, convert it to a legacy version number and see + // if the version number is in the list. + $legacy_version = LegacyVersionUtility::convertToLegacyVersion($semantic_version); + if ($legacy_version && array_key_exists($legacy_version, $supported_releases)) { + return TRUE; + } + // Neither the semantic version nor the legacy version are in the list + // of installable releases, so the release isn't supported. + return FALSE; + } + + /** + * Checks that the packages are secure and supported. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function validate(PreApplyEvent $event): void { + $active = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $staged = $this->composerInspector->getInstalledPackagesList($event->stage->getStageDirectory()); + $updated_packages = array_merge( + $staged->getPackagesNotIn($active)->getArrayCopy(), + $staged->getPackagesWithDifferentVersionsIn($active)->getArrayCopy() + ); + $unknown_packages = []; + $unsupported_packages = []; + foreach ($updated_packages as $package_name => $staged_package) { + // Only packages of the types 'drupal-module' or 'drupal-theme' that + // start with 'drupal/' will have update XML from drupal.org. + if (!in_array($staged_package->type, ['drupal-module', 'drupal-theme'], TRUE) + || !str_starts_with($package_name, 'drupal/')) { + continue; + } + $project_name = $staged[$package_name]->getProjectName(); + if (empty($project_name)) { + $unknown_packages[] = $package_name; + continue; + } + $semantic_version = $staged_package->version; + if (!$this->isSupportedRelease($project_name, $semantic_version)) { + $unsupported_packages[] = $this->t('@project_name (@package_name) @version', [ + '@project_name' => $project_name, + '@package_name' => $package_name, + '@version' => $semantic_version, + ]); + } + } + if ($unsupported_packages) { + $summary = $this->formatPlural( + count($unsupported_packages), + 'Cannot update because the following project version is not in the list of installable releases.', + 'Cannot update because the following project versions are not in the list of installable releases.' + ); + $event->addError($unsupported_packages, $summary); + } + if ($unknown_packages) { + $event->addError([ + $this->formatPlural( + count($unknown_packages), + 'Cannot update because the following new or updated Drupal package does not have project information: @unknown_packages', + 'Cannot update because the following new or updated Drupal packages do not have project information: @unknown_packages', + [ + '@unknown_packages' => implode(', ', $unknown_packages), + ], + ), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/package_manager/src/Validator/SymlinkValidator.php b/core/modules/package_manager/src/Validator/SymlinkValidator.php new file mode 100644 index 000000000000..c67b52b83adc --- /dev/null +++ b/core/modules/package_manager/src/Validator/SymlinkValidator.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathListFactoryInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\NoUnsupportedLinksExistInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Flags errors if unsupported symbolic links are detected. + * + * @see https://github.com/php-tuf/composer-stager/tree/develop/src/Domain/Service/Precondition#symlinks + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class SymlinkValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly NoUnsupportedLinksExistInterface $precondition, + private readonly PathFactoryInterface $pathFactory, + private readonly PathListFactoryInterface $pathListFactory, + ) {} + + /** + * Flags errors if the project root or stage directory contain symbolic links. + */ + public function validate(PreOperationStageEvent $event): void { + if ($event instanceof PreRequireEvent) { + // We don't need to check symlinks again during PreRequireEvent; this was + // already just validated during PreCreateEvent. + return; + } + $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); + + // The precondition requires us to pass both an active and stage directory, + // so if the stage hasn't been created or claimed yet, use the directory + // that contains this file, which contains only a few files and no symlinks, + // as the stage directory. The precondition itself doesn't care if the + // directory actually exists or not. + $stage_dir = __DIR__; + if ($event->stage->stageDirectoryExists()) { + $stage_dir = $event->stage->getStageDirectory(); + } + $stage_dir = $this->pathFactory->create($stage_dir); + + // Return early if no excluded paths were collected because this validator + // is dependent on knowing which paths to exclude when searching for + // symlinks. + // @see \Drupal\package_manager\StatusCheckTrait::runStatusCheck() + if ($event->excludedPaths === NULL) { + return; + } + + // The list of excluded paths is immutable, but the precondition may need to + // mutate it, so convert it back to a normal, mutable path list. + $exclusions = $this->pathListFactory->create(...$event->excludedPaths->getAll()); + + try { + $this->precondition->assertIsFulfilled($active_dir, $stage_dir, $exclusions); + } + catch (PreconditionException $e) { + $event->addErrorFromThrowable($e); + } + } + +} diff --git a/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php new file mode 100644 index 000000000000..3b31af7735cd --- /dev/null +++ b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Validator; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Checks that the file system is writable. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class WritableFileSystemValidator implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + use StringTranslationTrait; + + public function __construct(private readonly PathLocator $pathLocator) { + } + + /** + * Checks that the file system is writable. + * + * @todo Determine if 'is_writable()' is a sufficiently robust test across + * different operating systems in https://drupal.org/i/3348253. + */ + public function validate(PreOperationStageEvent $event): void { + $messages = []; + + $project_root = $this->pathLocator->getProjectRoot(); + + // If the web (Drupal) root and project root are different, validate the + // web root separately. + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $drupal_root = $project_root . DIRECTORY_SEPARATOR . $web_root; + if (!is_writable($drupal_root)) { + $messages[] = $this->t('The Drupal directory "@dir" is not writable.', [ + '@dir' => $drupal_root, + ]); + } + } + + if (!is_writable($project_root)) { + $messages[] = $this->t('The project root directory "@dir" is not writable.', [ + '@dir' => $project_root, + ]); + } + + $dir = $this->pathLocator->getVendorDirectory(); + if (!is_writable($dir)) { + $messages[] = $this->t('The vendor directory "@dir" is not writable.', ['@dir' => $dir]); + } + + // During pre-apply don't check whether the staging root is writable. + if ($event instanceof PreApplyEvent) { + if ($messages) { + $event->addError($messages, $this->t('The file system is not writable.')); + } + return; + } + // Ensure the staging root is writable. If it doesn't exist, ensure we will + // be able to create it. + $dir = $this->pathLocator->getStagingRoot(); + if (!file_exists($dir)) { + $dir = dirname($dir); + if (!is_writable($dir)) { + $messages[] = $this->t('The stage root directory will not able to be created at "@dir".', [ + '@dir' => $dir, + ]); + } + } + elseif (!is_writable($dir)) { + $messages[] = $this->t('The stage root directory "@dir" is not writable.', [ + '@dir' => $dir, + ]); + } + + if ($messages) { + $event->addError($messages, $this->t('The file system is not writable.')); + } + } + +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/README.md b/core/modules/package_manager/tests/fixtures/build_test_projects/README.md new file mode 100644 index 000000000000..40e2077edb4d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/README.md @@ -0,0 +1,11 @@ +# Why do we need the `updated_module` fixtures? +Because there is a need to thoroughly test the updating of a module. See `\Drupal\Tests\package_manager\Build\PackageUpdateTest`. + +This requires 2 versions (`1.0.0` and `1.1.0`) of the same module (`updated_module`), each with a different code bases. + +The test updates from one version to the next, and verifies the updated module's code base is actually used after the update: it verifies the updated logic of version of `\Drupal\updated_module\PostApplySubscriber` is being executed. + +`\Drupal\fixture_manipulator\FixtureManipulator` cannot manipulate code nor does it modify the file system: it only creates a "skeleton" extension. (See `\Drupal\fixture_manipulator\FixtureManipulator::addProjectAtPath()`.) + +# Why do we need the `alpha` fixtures? +To be able to test that `php-tuf/composer-stager` indeed only updates the package for which an update was requested (even though more updates are available), no fixture manipulation is allowed to occur. This requires updating a `path` composer package repository to first serve contain one version of a package, and then another. That is what these fixtures are used for. diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide new file mode 100644 index 000000000000..3029b2a6d90f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide @@ -0,0 +1,4 @@ +name: Alpha +type: module +core_version_requirement: ^9.7 || ^10 +project: alpha diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json new file mode 100644 index 000000000000..35db7d858c4e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/alpha", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide new file mode 100644 index 000000000000..3029b2a6d90f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide @@ -0,0 +1,4 @@ +name: Alpha +type: module +core_version_requirement: ^9.7 || ^10 +project: alpha diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json new file mode 100644 index 000000000000..f21a204a76df --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/alpha", + "type": "drupal-module", + "version": "1.1.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json new file mode 100644 index 000000000000..44b9518c5b53 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/main_module", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide new file mode 100644 index 000000000000..1852bfe5947f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide @@ -0,0 +1,4 @@ +name: Main module +type: module +core_version_requirement: ^9 || ^10 +project: main_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide new file mode 100644 index 000000000000..1383bc227ae2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide @@ -0,0 +1,4 @@ +name: Main Module Submodule +type: module +core_version_requirement: ^9 || ^10 +project: main_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json new file mode 100644 index 000000000000..777cd741d249 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/updated_module", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide new file mode 100644 index 000000000000..5d31bbdb5a80 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide @@ -0,0 +1,5 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing +project: updated_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module new file mode 100644 index 000000000000..0f90596c5ff1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return string[] + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json new file mode 100644 index 000000000000..6f997dad4cf4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/updated_module", + "type": "drupal-module", + "version": "1.1.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php new file mode 100644 index 000000000000..351933e341ce --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\updated_module; + +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Writes a file after staged changes are applied to the active directory. + * + * This event subscriber doesn't exist in version 1.0.0 of this module, so we + * use it to test that new event subscribers are picked up after staged changes + * have been applied. + */ +class PostApplySubscriber implements EventSubscriberInterface { + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + + /** + * Constructs a PostApplySubscriber. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(PathLocator $path_locator) { + $this->pathLocator = $path_locator; + } + + /** + * Writes a file when staged changes are applied to the active directory. + */ + public function postApply(): void { + $dir = $this->pathLocator->getProjectRoot(); + file_put_contents("$dir/bravo.txt", 'Bravo!'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PostApplyEvent::class => 'postApply', + ]; + } + +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide new file mode 100644 index 000000000000..5d31bbdb5a80 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide @@ -0,0 +1,5 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing +project: updated_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module new file mode 100644 index 000000000000..0f90596c5ff1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return string[] + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml new file mode 100644 index 000000000000..11bbfb9e9547 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml @@ -0,0 +1,7 @@ +services: + updated_module.post_apply_subscriber: + class: Drupal\updated_module\PostApplySubscriber + arguments: + - '@Drupal\package_manager\PathLocator' + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/fixtures/db_update.php b/core/modules/package_manager/tests/fixtures/db_update.php new file mode 100644 index 000000000000..7adacfb2e2e8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/db_update.php @@ -0,0 +1,14 @@ +<?php + +/** + * @file + * Contains a fake database update function for testing. + */ + +/** + * Here is a fake update hook. + * + * The schema version is the maximum possible value for a 32-bit integer. + */ +function package_manager_update_2147483647() { +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/.gitignore b/core/modules/package_manager/tests/fixtures/fake_site/.gitignore new file mode 100644 index 000000000000..e11552b41d40 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/.gitignore @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/README.md b/core/modules/package_manager/tests/fixtures/fake_site/README.md new file mode 100644 index 000000000000..9c9076f5b9fe --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/README.md @@ -0,0 +1,19 @@ +This directory is used as the basis for quasi-functional tests of Package Manager based on `\Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase`. It provides a bare-bones simulation of a real Drupal site layout, including: + +* A `.git` directory and `.gitignore` file +* A Drupal core directory with npm modules installed +* An `example` contrib module with its own `.git` directory and npm modules +* A directory in which to store private files (`private`) +* A default site directory with site-specific config files, as well as default versions of them +* A "real" site directory (`example.com`), with a public `files` directory, site-specific config files, and a SQLite database +* A `simpletest` directory containing artifacts from automated tests +* A `vendor` directory to contain installed Composer dependencies +* `composer.json` and `composer.lock` files + +Tests which use this mock site will clone it into a temporary location, then run real Composer commands in it, along with other Package Manager operations, and make assertions about the results. It's important to understand that this mock site is not at all bootable or usable as a real Drupal site. But as far as Package Manager and Composer are concerned, it IS a completely valid project that can go through all phases of the stage life cycle. + +The files named `ignore.txt` are named that way because Package Manager should ALWAYS ignore them when creating a staged copy of this mock site -- that is, they should never be copied into the stage directory, or removed from their original place, by Package Manager. + +The `.git` directories are named `_git` because we cannot commit `.git` directories to our git repository. When a test clones this mock site, these directories are automatically renamed to `.git` in the copy. + +This fixture can be re-created at any time by running, from the repository root, `php scripts/PackageManagerFixtureCreator.php`. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt new file mode 100644 index 000000000000..b70fb51506c2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt @@ -0,0 +1,4 @@ +This file should never be staged. + +The parent directory will be renamed to .git. +@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject() diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/composer.json new file mode 100644 index 000000000000..59927347a362 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.json @@ -0,0 +1,47 @@ +{ + "name": "fake/site", + "description": "bull shit", + "version": "1.2.4", + "require": { + "drupal/core-recommended": "9.8.0", + "drupal/core": "9.8.0" + }, + "require-dev": { + "drupal/core-dev": "^9" + }, + "extra": { + "boo": "boo boo", + "foo": { + "dev": "2.x-dev" + }, + "foo-bar": true, + "boo-far": { + "foo": 1.23, + "bar": 134, + "foo-bar": null + }, + "baz": null, + "installer-paths": { + "modules/contrib/{$name}": ["type:drupal-module"], + "profiles/contrib/{$name}": ["type:drupal-profile"], + "themes/contrib/{$name}": ["type:drupal-theme"] + } + }, + "repositories": { + "fake-packages": { + "type": "composer", + "url": "./" + }, + "custom-package": { + "type": "path", + "url": "custom/package" + }, + "packagist.org": false + }, + "minimum-stability": "stable", + "config": { + "allow-plugins": { + "drupal/core-composer-scaffold": false + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.lock b/core/modules/package_manager/tests/fixtures/fake_site/composer.lock new file mode 100644 index 000000000000..fcf06e98c789 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.lock @@ -0,0 +1,88 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e14b8d5fb12bac6df9c78c41c977e9e5", + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + }, + "type": "drupal-core", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "description": "A fake version of drupal/core" + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + }, + "type": "project", + "description": "A fake version of drupal/core-recommended" + } + ], + "packages-dev": [ + { + "name": "drupal/core-dev", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + }, + "type": "package", + "description": "A fake version of drupal/core-dev" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt new file mode 100644 index 000000000000..b70fb51506c2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt @@ -0,0 +1,4 @@ +This file should never be staged. + +The parent directory will be renamed to .git. +@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject() diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml new file mode 100644 index 000000000000..046fc058cf87 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml @@ -0,0 +1,3 @@ +# This file should be staged. +name: Example +type: module diff --git a/core/modules/package_manager/tests/fixtures/fake_site/packages.json b/core/modules/package_manager/tests/fixtures/fake_site/packages.json new file mode 100644 index 000000000000..6bcbca9045e6 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/packages.json @@ -0,0 +1,101 @@ +{ + "packages": { + "drupal/core-recommended": { + "9.8.0": { + "name": "drupal/core-recommended", + "description": "A fake version of drupal/core-recommended", + "type": "project", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + } + } + }, + "drupal/core-dev": { + "9.8.0": { + "name": "drupal/core-dev", + "description": "A fake version of drupal/core-dev", + "type": "package", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + } + } + }, + "drupal/core": { + "9.8.0": { + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + } + } + }, + "cweagans/composer-patches": { + "1.7.333": { + "name": "cweagans/composer-patches", + "description": "A fake version of cweagans/composer-patches", + "type": "composer-plugin", + "version": "1.7.333", + "extra": { + "class": "\\cweagans\\Fake\\ComposerPatches" + }, + "dist": { + "type": "path", + "url": "../path_repos/cweagans--composer-patches" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "autoload": { + "psr-4": { + "cweagans\\Fake\\": "src" + } + } + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt b/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml new file mode 100644 index 000000000000..95dde1725d47 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml @@ -0,0 +1,2 @@ +# This file should be staged because it's scaffolded into place by Drupal core. +services: {} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php new file mode 100644 index 000000000000..0d23e8400696 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should be staged because it's scaffolded into place by Drupal core. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml new file mode 100644 index 000000000000..cbc4434e8f2b --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml @@ -0,0 +1,2 @@ +# This file should never be staged. +must_not_be: 'empty' diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php new file mode 100644 index 000000000000..15b43d28125c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php new file mode 100644 index 000000000000..15b43d28125c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt new file mode 100644 index 000000000000..0087269e33e5 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt @@ -0,0 +1 @@ +This file should be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml new file mode 100644 index 000000000000..f408d89e28e9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml @@ -0,0 +1,2 @@ +# This file should never be staged. +key: "value" diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php new file mode 100644 index 000000000000..15b43d28125c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php new file mode 100644 index 000000000000..15b43d28125c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess new file mode 100644 index 000000000000..e11552b41d40 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json new file mode 100644 index 000000000000..d02150712c5c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json @@ -0,0 +1,85 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + }, + "type": "drupal-core", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "installation-source": "dist", + "description": "A fake version of drupal/core", + "install-path": "../drupal/core" + }, + { + "name": "drupal/core-dev", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + }, + "type": "package", + "installation-source": "dist", + "description": "A fake version of drupal/core-dev", + "install-path": "../drupal/core-dev" + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + }, + "type": "project", + "installation-source": "dist", + "description": "A fake version of drupal/core-recommended", + "install-path": "../drupal/core-recommended" + } + ], + "dev": true, + "dev-package-names": [ + "drupal/core-dev" + ] +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php new file mode 100644 index 000000000000..652db9ee38dc --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php @@ -0,0 +1,56 @@ +<?php + +/** + * @file + */ + +return [ + 'root' => [ + 'name' => 'fake/site', + 'pretty_version' => '1.2.4', + 'version' => '1.2.4.0', + 'reference' => NULL, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev' => TRUE, + ], + 'versions' => [ + 'drupal/core' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => '31fd2270701526555acae45a3601c777e35508d5', + 'type' => 'drupal-core', + 'install_path' => __DIR__ . '/../drupal/core', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + 'drupal/core-dev' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => 'b99a99a11ff2779b5e4c5787dc43575382a3548c', + 'type' => 'package', + 'install_path' => __DIR__ . '/../drupal/core-dev', + 'aliases' => [], + 'dev_requirement' => TRUE, + ], + 'drupal/core-recommended' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => '112e4f7cfe8312457cd0eb58dcbffebc148850d8', + 'type' => 'project', + 'install_path' => __DIR__ . '/../drupal/core-recommended', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + 'fake/site' => [ + 'pretty_version' => '1.2.4', + 'version' => '1.2.4.0', + 'reference' => NULL, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + ], +]; diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json new file mode 100644 index 000000000000..0cbfd9f7b5fa --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json @@ -0,0 +1 @@ +{"name":"drupal\/core-dev","description": "A fake version of drupal/core-dev","type":"package","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json new file mode 100644 index 000000000000..ca65289753d1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json @@ -0,0 +1 @@ +{"name":"drupal/core-recommended","description": "A fake version of drupal/core-recommended","type":"project","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json new file mode 100644 index 000000000000..e4412436131d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json @@ -0,0 +1,41 @@ +{ + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config new file mode 100644 index 000000000000..08874eba8bb9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json new file mode 100644 index 000000000000..c48abf3e8b94 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json @@ -0,0 +1,15 @@ +{ + "name": "cweagans/composer-patches", + "description": "A fake version of cweagans/composer-patches", + "type": "composer-plugin", + "version": "1.7.333", + "extra": { + "class": "\\cweagans\\Fake\\ComposerPatches" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "autoload": { + "psr-4": {"cweagans\\Fake\\": "src"} + } +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php new file mode 100644 index 000000000000..65f431d01ba6 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php @@ -0,0 +1,29 @@ +<?php + +namespace cweagans\Fake; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Plugin\PluginInterface; + +/** + * Dummy composer plugin implementation. + */ +class ComposerPatches implements PluginInterface { + + /** + * {@inheritdoc} + */ + public function activate(Composer $composer, IOInterface $io) {} + + /** + * {@inheritdoc} + */ + public function deactivate(Composer $composer, IOInterface $io) {} + + /** + * {@inheritdoc} + */ + public function uninstall(Composer $composer, IOInterface $io) {} + +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json new file mode 100644 index 000000000000..0cbfd9f7b5fa --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json @@ -0,0 +1 @@ +{"name":"drupal\/core-dev","description": "A fake version of drupal/core-dev","type":"package","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json new file mode 100644 index 000000000000..ca65289753d1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json @@ -0,0 +1 @@ +{"name":"drupal/core-recommended","description": "A fake version of drupal/core-recommended","type":"project","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json new file mode 100644 index 000000000000..e4412436131d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json @@ -0,0 +1,41 @@ +{ + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json new file mode 100644 index 000000000000..cee4fc43d6a7 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json @@ -0,0 +1,8 @@ +{ + "name": "drupal/main_module_submodule", + "type": "metapackage", + "version": "1.0.0", + "require": { + "drupal/main_module": "*" + } +} diff --git a/core/modules/package_manager/tests/fixtures/post_update.php b/core/modules/package_manager/tests/fixtures/post_update.php new file mode 100644 index 000000000000..a596cbe1d0f8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/post_update.php @@ -0,0 +1,12 @@ +<?php + +/** + * @file + * Contains a fake database post-update function for testing. + */ + +/** + * Here is a fake post-update hook. + */ +function package_manager_post_update_test() { +} diff --git a/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml new file mode 100644 index 000000000000..98c0cf442f5d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of aaa_update_test module, all of which are secure, in order: +- 8.x-3.0, which is in an unsupported branch +- 8.x-2.1 +- 8.x-2.1-beta1 +- 8.x-2.1-alpha1 +- 8.x-2.0 +- 8.x-2.0-beta1 +- 8.x-2.0-alpha1 +- 8.x-1.1 +- 8.x-1.1-beta1 +- 8.x-1.1-alpha1 +- 8.x-1.0 +- 8.x-1.0-beta1 +- 8.x-1.0-alpha1 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>AAA Update test</title> +<short_name>aaa_update_test</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.x-2.,8.x-1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/aaa_update_test</link> + <terms> + <term><name>Projects</name><value>AAA Update test project</value></term> + </terms> +<releases> + <release> + <name>AAA Update test 8.x-3.0</name> + <version>8.x-3.0</version> + <tag>8.x-3.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-3-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-3-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.1</name> + <version>8.x-2.1</version> + <tag>8.x-2.1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.1-beta1</name> + <version>8.x-2.1-beta1</version> + <tag>8.x-2.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1-beta1.tar.gz</download_link> + <date>1579011300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.1-alpha1</name> + <version>8.x-2.1-alpha1</version> + <tag>8.x-2.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1-alpha1.tar.gz</download_link> + <date>1576419300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.0</name> + <version>8.x-2.0</version> + <tag>8.x-2.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0.tar.gz</download_link> + <date>1573827300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.0-beta1</name> + <version>8.x-2.0-beta1</version> + <tag>8.x-2.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0-beta1.tar.gz</download_link> + <date>1571235300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-2.0-alpha1</name> + <version>8.x-2.0-alpha1</version> + <tag>8.x-2.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0-alpha1.tar.gz</download_link> + <date>1568643300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.1</name> + <version>8.x-1.1</version> + <tag>8.x-1.1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1.tar.gz</download_link> + <date>1566051300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.1-beta1</name> + <version>8.x-1.1-beta1</version> + <tag>8.x-1.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1-beta1.tar.gz</download_link> + <date>1563459300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.1-alpha1</name> + <version>8.x-1.1-alpha1</version> + <tag>8.x-1.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1-alpha1.tar.gz</download_link> + <date>1560867300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.0</name> + <version>8.x-1.0</version> + <tag>8.x-1.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0.tar.gz</download_link> + <date>1558275300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.0-beta1</name> + <version>8.x-1.0-beta1</version> + <tag>8.x-1.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0-beta1.tar.gz</download_link> + <date>1555683300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>AAA Update test 8.x-1.0-alpha1</name> + <version>8.x-1.0-alpha1</version> + <tag>8.x-1.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0-alpha1.tar.gz</download_link> + <date>1553091300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml new file mode 100644 index 000000000000..5981d8cc879b --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageInstallTest. + +Contains metadata about the following (fake) releases of alpha module, all of which are secure, in order: +- 1.1.0 +- 1.0.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Alpha</title> +<short_name>alpha</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>1.1.,1.0.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/alpha</link> + <terms> + <term><name>Projects</name><value>Alpha project</value></term> + </terms> +<releases> + <release> + <name>Alpha 1.1.0</name> + <version>1.1.0</version> + <tag>1.1.0</tag> + <status>published</status> + <release_link>http://example.com/alpha-1-1-0-release</release_link> + <download_link>http://example.com/alpha-1-1-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Alpha 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/alpha-1-0-0-release</release_link> + <download_link>http://example.com/alpha-1-0-0.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml new file mode 100644 index 000000000000..9b34064b5b08 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml @@ -0,0 +1,214 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormTest + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 10.0.0 +- 9.7.2 +- 9.7.1 +- 9.7.0 +- 9.6.1 +- 9.6.0 +- 9.5.1 +- 9.5.0 +- 9.4.0, which is in an unsupported branch +- 9.7.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.5.,9.6.,9.7.,10.0.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 10.0.0</name> + <version>10.0.0</version> + <status>published</status> + <release_link>http://example.com/drupal-10-0-0-release</release_link> + <download_link>http://example.com/drupal-10-0-0.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.2</name> + <version>9.7.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-2-release</release_link> + <download_link>http://example.com/drupal-9-7-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.5.1</name> + <version>9.5.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-5-1-release</release_link> + <download_link>http://example.com/drupal-9-5-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.5.0</name> + <version>9.5.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-5-0-release</release_link> + <download_link>http://example.com/drupal-9-5-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.4.0</name> + <version>9.4.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-4-0-release</release_link> + <download_link>http://example.com/drupal-9-4-0.tar.gz</download_link> + <date>1240424421</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.x-dev</name> + <version>9.7.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-7-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml new file mode 100644 index 000000000000..2f3e6f23e7fd --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml new file mode 100644 index 000000000000..c34d58ce5da2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Functional\UpdaterFormTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.0-beta1 +- 9.7.0 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.,9.7.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-beta1</name> + <version>9.8.0-beta1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-beta1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-beta1-.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml new file mode 100644 index 000000000000..c7c60b26be13 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Functional\UpdaterFormTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.0-rc1 +- 9.7.0 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.,9.7.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-rc1</name> + <version>9.8.0-rc1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-rc1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-rc1-.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml new file mode 100644 index 000000000000..e2c16bdd0138 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about releases of Drupal core with no supported branches: +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches/> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml new file mode 100644 index 000000000000..e820b03c893a --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml @@ -0,0 +1,157 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest. + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.1-rc3 +- 9.8.1-beta2 +- 9.8.1-alpha1 +- 9.7.1 +- 9.7.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.1-rc3</name> + <version>9.8.1-rc3</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-rc3-release</release_link> + <download_link>http://example.com/drupal-9-8-1-rc3.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1-beta2</name> + <version>9.8.1-beta2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta2-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta2.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1-alpha1</name> + <version>9.8.1-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-1-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1-beta2</name> + <version>9.8.1-beta2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta2-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta2.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml new file mode 100644 index 000000000000..93098f02a921 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml @@ -0,0 +1,122 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\auto_updates_extensions\Build\ModuleUpdateTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Build\CoreUpdateTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Functional\ClickableHelpTest +- \Drupal\Tests\auto_updates\Functional\StatusCheckTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormNoRecommendedReleaseMessageTest +- \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest +- \Drupal\Tests\auto_updates\Kernel\UpdaterTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\CronFrequencyValidatorTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\StatusCheckerTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.1, which is a security release +- 9.8.0, which is insecure +- 9.8.0-alpha1, which is insecure +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1-.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml new file mode 100644 index 000000000000..e3030e71c3c0 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about releases of Drupal core with <supported_branches></supported_branches> not set: +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml new file mode 100644 index 000000000000..128010a9b5b1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Kernel\ReleaseChooserTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1, which is a security update +- 9.8.1-beta1, which is a security update +- 9.8.0, which is insecure +- 9.8.0-alpha1 +- 9.7.1, which is a security update +- 9.7.0, which is insecure +- 9.7.0-alpha1 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1-beta1</name> + <version>9.8.1-beta1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta1-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security release</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml new file mode 100644 index 000000000000..6220cae3b076 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml @@ -0,0 +1,237 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1, which is unsupported +- 9.8.0, which is unpublished +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Unsupported</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>unpublished</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml new file mode 100644 index 000000000000..cf2384f0347e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml @@ -0,0 +1,241 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\auto_updates_extensions\Functional\UpdaterFormTest +- \Drupal\Tests\auto_updates_extensions\Kernel\Validator\UpdateReleaseValidatorTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormNoRecommendedReleaseMessageTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormTest +- \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicy\SupportedBranchInstalledTest + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.2 +- 9.8.1 +- 9.8.0 +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml new file mode 100644 index 000000000000..e0d599c9b0ba --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml @@ -0,0 +1,233 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1 +- 9.8.0 +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev + +What's special about this file is that the project as a whole has a status other than "published". +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>any status besides published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + </terms> + </release> + </releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml b/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml new file mode 100644 index 000000000000..a1702e42f725 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageInstallTest. + +Contains metadata about the following (fake) releases of main_module, all of which are secure, in order: +- 1.0.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Main Module</title> +<short_name>main_module</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>1.0.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/main_module</link> + <terms> + <term><name>Projects</name><value>Main Module project</value></term> + </terms> +<releases> + <release> + <name>Main Module 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/main_module-1-0-0-release</release_link> + <download_link>http://example.com/main_module-1-0-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml b/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml new file mode 100644 index 000000000000..dd5c12a33a69 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml @@ -0,0 +1,166 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest + +Contains metadata about the following (fake) releases of package_manager_test_update module, all of which are secure, in order: +- 7.0.1 +- 7.0.0 +- 7.0.0-alpha1 +- 8.x-6.2 +- 8.x-6.1 +- 8.x-6.0 +- 8.x-6.0-alpha1 +- 7.0.x-dev +- 8.x-6.x-dev +- 8.x-5.x - An invalid release to ensure invalid releases do not affect processing other releases. +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Package Manager Test Update</title> +<short_name>package_manager_test_update</short_name> +<dc:creator>Package Manager</dc:creator> +<supported_branches>7.0.,8.x-6.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/package_manager_test_update</link> + <terms> + <term><name>Projects</name><value>Package Manager Test Update project</value></term> + </terms> +<releases> + <release> + <name>Package Manager Test Update 7.0.1</name> + <version>7.0.1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 7.0.0</name> + <version>7.0.0</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 7.0.0-alpha1</name> + <version>7.0.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-6.2</name> + <version>8.x-6.2</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-2-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-6.1</name> + <version>8.x-6.1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-6.0</name> + <version>8.x-6.0</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-6.0-alpha1</name> + <version>8.x-6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 7.0.0-dev</name> + <version>7.0.x-dev</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-dev-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0-dev.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-6.x-dev</name> + <version>8.x-6.x-dev</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-dev-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0-dev.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Test Update 8.x-5.x</name> + <version>8.x-5.x</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-5-0-dev-release</release_link> + <download_link/> + <date/> + <filesize/> + <files> + <file> + <url/> + <archive_type/> + <md5/> + <size/> + </file> + <file> + <url/> + <archive_type/> + <md5/> + <size/> + </file> + </files> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml new file mode 100644 index 000000000000..69779f4ce90e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of Package Manager Theme, all of which are secure, in order: +- 8.2.0, which is in an unsupported branch +- 8.1.1 +- 8.1.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Package Manager Theme</title> +<short_name>package_manager_theme</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.0.,8.1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/package_manager_theme</link> + <terms> + <term><name>Projects</name><value>Package Manager Theme project</value></term> + </terms> +<releases> + <release> + <name>Package Manager Theme 8.2.0</name> + <version>8.2.0</version> + <tag>8.2.0</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-2-0-release</release_link> + <download_link>http://example.com/package_manager_theme-8-2-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Theme 8.1.1</name> + <version>8.1.1</version> + <tag>8.1.1</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-1-1-release</release_link> + <download_link>http://example.com/package_manager_theme-8-1-1.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Package Manager Theme 8.1.0</name> + <version>8.1.0</version> + <tag>8.1.0</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-1-0-release</release_link> + <download_link>http://example.com/package_manager_theme-8-1-0.tar.gz</download_link> + <date>1573827300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml new file mode 100644 index 000000000000..ddebbd803cf3 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of semver_test module, all of which are secure, in order: +- 8.2.0, which is in an unsupported branch +- 8.1.1 +- 8.1.1-beta1 +- 8.1.1-alpha1 +- 8.1.0 +- 8.1.0-beta1 +- 8.1.0-alpha1 +- 8.0.1 +- 8.0.1-beta1 +- 8.0.1-alpha1 +- 8.0.0 +- 8.0.0-beta1 +- 8.0.0-alpha1 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Semver Test</title> +<short_name>semver_test</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.0.,8.1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/semver_test</link> + <terms> + <term><name>Projects</name><value>Semver Test project</value></term> + </terms> +<releases> + <release> + <name>Semver Test 8.2.0</name> + <version>8.2.0</version> + <tag>8.2.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-2-0-release</release_link> + <download_link>http://example.com/semver_test-8-2-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.1</name> + <version>8.1.1</version> + <tag>8.1.1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.1-beta1</name> + <version>8.1.1-beta1</version> + <tag>8.1.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1-beta1.tar.gz</download_link> + <date>1579011300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.1-alpha1</name> + <version>8.1.1-alpha1</version> + <tag>8.1.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1-alpha1.tar.gz</download_link> + <date>1576419300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.0</name> + <version>8.1.0</version> + <tag>8.1.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-release</release_link> + <download_link>http://example.com/semver_test-8-1-0.tar.gz</download_link> + <date>1573827300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.0-beta1</name> + <version>8.1.0-beta1</version> + <tag>8.1.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-1-0-beta1.tar.gz</download_link> + <date>1571235300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.1.0-alpha1</name> + <version>8.1.0-alpha1</version> + <tag>8.1.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-1-0-alpha1.tar.gz</download_link> + <date>1568643300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.1</name> + <version>8.0.1</version> + <tag>8.0.1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1.tar.gz</download_link> + <date>1566051300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.1-beta1</name> + <version>8.0.1-beta1</version> + <tag>8.0.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1-beta1.tar.gz</download_link> + <date>1563459300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.1-alpha1</name> + <version>8.0.1-alpha1</version> + <tag>8.0.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1-alpha1.tar.gz</download_link> + <date>1560867300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.0</name> + <version>8.0.0</version> + <tag>8.0.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-release</release_link> + <download_link>http://example.com/semver_test-8-0-0.tar.gz</download_link> + <date>1558275300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.0-beta1</name> + <version>8.0.0-beta1</version> + <tag>8.0.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-0-0-beta1.tar.gz</download_link> + <date>1555683300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Semver Test 8.0.0-alpha1</name> + <version>8.0.0-alpha1</version> + <tag>8.0.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-0-0-alpha1.tar.gz</download_link> + <date>1553091300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml new file mode 100644 index 000000000000..ca709ff98bd5 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageUpdateTest. + +Contains metadata about the following (fake) releases of updated_module, all of which are secure, in order: +- 1.1.0 +- 1.0.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Updated Module</title> +<short_name>updated_module</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>1.1.,1.0.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/alpha</link> + <terms> + <term><name>Projects</name><value>Updated Module project</value></term> + </terms> +<releases> + <release> + <name>Updated Module 1.1.0</name> + <version>1.1.0</version> + <tag>1.1.0</tag> + <status>published</status> + <release_link>http://example.com/updated_module-1-1-0-release</release_link> + <download_link>http://example.com/updated_module-1-1-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Updated Module 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/updated_module-1-0-0-release</release_link> + <download_link>http://example.com/updated_module-1-0-0.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml new file mode 100644 index 000000000000..cebed1395be9 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml @@ -0,0 +1,4 @@ +name: 'Fixture manipulator' +description: 'Manipulate fixtures for tests.' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml new file mode 100644 index 000000000000..4ea503f7188a --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + Drupal\fixture_manipulator\StageFixtureManipulator: + decorates: 'PhpTuf\ComposerStager\API\Core\BeginnerInterface' + Drupal\fixture_manipulator\ProcessFactory: + decorates: 'PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface' diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php new file mode 100644 index 000000000000..d486eb2b5aa1 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\package_manager\PathLocator; + +/** + * A fixture manipulator for the active directory. + */ +final class ActiveFixtureManipulator extends FixtureManipulator { + + /** + * {@inheritdoc} + */ + public function commitChanges(?string $dir = NULL): void { + if ($dir) { + throw new \UnexpectedValueException("$dir cannot be specific for a ActiveFixtureManipulator instance"); + } + $dir = \Drupal::service(PathLocator::class)->getProjectRoot(); + parent::doCommitChanges($dir); + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php new file mode 100644 index 000000000000..a0410cf75143 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php @@ -0,0 +1,645 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\NestedArray; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; +use Symfony\Component\Filesystem\Filesystem as SymfonyFileSystem; +use Drupal\Component\Serialization\Yaml; + +/** + * Manipulates a test fixture using Composer commands. + * + * The composer.json file CANNOT be safely created or modified using the + * json_encode() function, because Composer does not use a real JSON parser — it + * updates composer.json using \Composer\Json\JsonManipulator, which is known to + * choke in a number of scenarios. + * + * @see https://www.drupal.org/i/3346628 + */ +class FixtureManipulator { + + protected const PATH_REPO_STATE_KEY = self::class . '-path-repo-base'; + + /** + * Whether changes are currently being committed. + * + * @var bool + */ + private bool $committingChanges = FALSE; + + /** + * Arguments to manipulator functions. + * + * @var array + */ + private array $manipulatorArguments = []; + + /** + * Whether changes have been committed. + * + * @var bool + */ + protected bool $committed = FALSE; + + /** + * The fixture directory. + * + * @var string + */ + protected string $dir; + + /** + * Validate the fixtures still passes `composer validate`. + */ + private function validateComposer(): void { + /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */ + $runner = \Drupal::service(ComposerProcessRunnerInterface::class); + $runner->run([ + 'validate', + '--check-lock', + '--no-check-publish', + '--with-dependencies', + '--no-interaction', + '--ansi', + '--no-cache', + "--working-dir={$this->dir}", + // Unlike ComposerInspector::validate(), explicitly do NOT validate + // plugins, to allow for testing edge cases. + '--no-plugins', + // Dummy packages are not meant for publishing, so do not validate that. + '--no-check-publish', + '--no-check-version', + ]); + } + + /** + * Adds a package. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * @param bool $allow_plugins + * Whether to use the '--no-plugins' option. + * @param array|null $extra_files + * An array extra files to create in the package. The keys are the file + * paths under package and values are the file contents. + */ + public function addPackage(array $package, bool $is_dev_requirement = FALSE, bool $allow_plugins = FALSE, ?array $extra_files = NULL): self { + if (!$this->committingChanges) { + // To pass Composer validation all packages must have a version specified. + if (!isset($package['version'])) { + $package['version'] = '1.2.3'; + } + $this->queueManipulation('addPackage', [$package, $is_dev_requirement, $allow_plugins, $extra_files]); + return $this; + } + + // Basic validation so we can defer the rest to `composer` commands. + foreach (['name', 'type'] as $required_key) { + if (!isset($package[$required_key])) { + throw new \UnexpectedValueException("The '$required_key' is required when calling ::addPackage()."); + } + } + if (!preg_match('/\w+\/\w+/', $package['name'])) { + throw new \UnexpectedValueException(sprintf("'%s' is not a valid package name.", $package['name'])); + } + + // `composer require` happily will re-require already required packages. + // Prevent test authors from thinking this has any effect when it does not. + $json = $this->runComposerCommand(['show', '--name-only', '--format=json'])->stdout; + $installed_package_names = array_column(json_decode($json)?->installed ?? [], 'name'); + if (in_array($package['name'], $installed_package_names)) { + throw new \LogicException(sprintf("Expected package '%s' to not be installed, but it was.", $package['name'])); + } + + $repo_path = $this->addRepository($package); + if (is_null($extra_files) && isset($package['type']) && in_array($package['type'], ['drupal-module', 'drupal-theme', 'drupal-profile'], TRUE)) { + // For Drupal projects if no files are provided create an info.yml file + // that assumes the project and package names match. + [, $package_name] = explode('/', $package['name']); + $project_name = str_replace('-', '_', $package_name); + $project_info_data = [ + 'name' => $package['name'], + 'project' => $project_name, + ]; + $extra_files["$project_name.info.yml"] = Yaml::encode($project_info_data); + } + if (!empty($extra_files)) { + $fs = new SymfonyFileSystem(); + foreach ($extra_files as $file_name => $file_contents) { + if (str_contains($file_name, DIRECTORY_SEPARATOR)) { + $file_dir = dirname("$repo_path/$file_name"); + if (!is_dir($file_dir)) { + $fs->mkdir($file_dir); + } + } + assert(file_put_contents("$repo_path/$file_name", $file_contents) !== FALSE); + } + } + return $this->requirePackage($package['name'], $package['version'], $is_dev_requirement, $allow_plugins); + } + + /** + * Requires a package. + * + * @param string $package + * A package name. + * @param string $version + * A version constraint. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * @param bool $allow_plugins + * Whether to use the '--no-plugins' option. + */ + public function requirePackage(string $package, string $version, bool $is_dev_requirement = FALSE, bool $allow_plugins = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('requirePackage', func_get_args()); + return $this; + } + + $command_options = ['require', "$package:$version"]; + if ($is_dev_requirement) { + $command_options[] = '--dev'; + } + // Unlike ComposerInspector::validate(), explicitly do NOT validate plugins. + if (!$allow_plugins) { + $command_options[] = '--no-plugins'; + } + $this->runComposerCommand($command_options); + return $this; + } + + /** + * Modifies a package's composer.json properties. + * + * @param string $package_name + * The name of the package to modify. + * @param string $version + * The version to use for the modified package. Can be the same as the + * original version, or a different version. + * @param array $config + * The config to be added to the package's composer.json. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * + * @see \Composer\Command\ConfigCommand + */ + public function modifyPackageConfig(string $package_name, string $version, array $config, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('modifyPackageConfig', func_get_args()); + return $this; + } + $package = [ + 'name' => $package_name, + 'version' => $version, + ] + $config; + $this->addRepository($package); + $this->runComposerCommand(array_filter(['require', "$package_name:$version", $is_dev_requirement ? '--dev' : NULL])); + return $this; + } + + /** + * Sets a package version. + * + * @param string $package_name + * The package name. + * @param string $version + * The version. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * + * @return $this + */ + public function setVersion(string $package_name, string $version, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('setVersion', func_get_args()); + return $this; + } + return $this->modifyPackageConfig($package_name, $version, [], $is_dev_requirement); + } + + /** + * Removes a package. + * + * @param string $name + * The name of the package to remove. + * @param bool $is_dev_requirement + * Whether the package is a developer requirement. + */ + public function removePackage(string $name, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('removePackage', func_get_args()); + return $this; + } + + $output = $this->runComposerCommand(array_filter(['remove', $name, $is_dev_requirement ? '--dev' : NULL])); + // `composer remove` will not set exit code 1 whenever a non-required + // package is being removed. + // @see \Composer\Command\RemoveCommand + if (str_contains($output->stderr, 'not required in your composer.json and has not been removed')) { + $output->stderr = str_replace("./composer.json has been updated\n", '', $output->stderr); + throw new \LogicException($output->stderr); + } + return $this; + } + + /** + * Adds a project at a path. + * + * @param string $path + * The path. + * @param string|null $project_name + * (optional) The project name. If none is specified the last part of the + * path will be used. + * @param string|null $file_name + * (optional) The file name. If none is specified the project name will be + * used. + */ + public function addProjectAtPath(string $path, ?string $project_name = NULL, ?string $file_name = NULL): self { + if (!$this->committingChanges) { + $this->queueManipulation('addProjectAtPath', func_get_args()); + return $this; + } + $path = $this->dir . "/$path"; + if (file_exists($path)) { + throw new \LogicException("'$path' path already exists."); + } + $fs = new SymfonyFileSystem(); + $fs->mkdir($path); + if ($project_name === NULL) { + $project_name = basename($path); + } + if ($file_name === NULL) { + $file_name = "$project_name.info.yml"; + } + assert(file_put_contents("$path/$file_name", Yaml::encode(['project' => $project_name])) !== FALSE); + return $this; + } + + /** + * Modifies core packages. + * + * @param string $version + * Target version. + */ + public function setCorePackageVersion(string $version): self { + $this->setVersion('drupal/core', $version); + $this->setVersion('drupal/core-recommended', $version); + $this->setVersion('drupal/core-dev', $version); + return $this; + } + + /** + * Modifies the project root's composer.json properties. + * + * @see \Composer\Command\ConfigCommand + * + * @param array $additional_config + * The configuration to add. + */ + public function addConfig(array $additional_config): self { + if (empty($additional_config)) { + throw new \InvalidArgumentException('No config to add.'); + } + + if (!$this->committingChanges) { + $this->queueManipulation('addConfig', func_get_args()); + return $this; + } + $clean_value = function ($value) { + return $value === FALSE ? 'false' : $value; + }; + + foreach ($additional_config as $key => $value) { + $command = ['config']; + if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES); + $command[] = '--json'; + } + else { + $value = $clean_value($value); + } + $command[] = $key; + $command[] = $value; + $this->runComposerCommand($command); + } + $this->runComposerCommand(['update', '--lock']); + + return $this; + } + + /** + * Commits the changes to the directory. + */ + public function commitChanges(string $dir): void { + $this->doCommitChanges($dir); + $this->committed = TRUE; + } + + /** + * Commits all the changes. + * + * @param string $dir + * The directory to commit the changes to. + */ + final protected function doCommitChanges(string $dir): void { + if ($this->committed) { + throw new \BadMethodCallException('Already committed.'); + } + $this->dir = $dir; + $this->setUpRepos(); + $this->committingChanges = TRUE; + $manipulator_arguments = $this->getQueuedManipulationItems(); + $this->clearQueuedManipulationItems(); + foreach ($manipulator_arguments as $method => $argument_sets) { + // @todo Attempt to make fewer Composer calls in + // https://drupal.org/i/3345639. + foreach ($argument_sets as $argument_set) { + $this->{$method}(...$argument_set); + } + } + $this->committed = TRUE; + $this->committingChanges = FALSE; + $this->validateComposer(); + } + + /** + * Ensure that changes were committed before object is destroyed. + */ + public function __destruct() { + if (!$this->committed && !empty($this->manipulatorArguments)) { + throw new \LogicException('commitChanges() must be called.'); + } + } + + /** + * Creates an empty .git folder after being provided a path. + */ + public function addDotGitFolder(string $path): self { + if (!$this->committingChanges) { + $this->queueManipulation('addDotGitFolder', func_get_args()); + return $this; + } + if (!is_dir($path)) { + throw new \LogicException("No directory exists at $path."); + } + $fs = new SymfonyFileSystem(); + $git_directory_path = $path . "/.git"; + if (is_dir($git_directory_path)) { + throw new \LogicException("A .git directory already exists at $path."); + } + $fs->mkdir($git_directory_path); + return $this; + } + + /** + * Queues manipulation arguments to be called in ::doCommitChanges(). + * + * @param string $method + * The method name. + * @param array $arguments + * The arguments. + */ + protected function queueManipulation(string $method, array $arguments): void { + $this->manipulatorArguments[$method][] = $arguments; + } + + /** + * Clears all queued manipulation items. + */ + protected function clearQueuedManipulationItems(): void { + $this->manipulatorArguments = []; + } + + /** + * Gets all queued manipulation items. + * + * @return array + * The queued manipulation items as set by calls to ::queueManipulation(). + */ + protected function getQueuedManipulationItems(): array { + return $this->manipulatorArguments; + } + + protected function runComposerCommand(array $command_options): OutputCallbackInterface { + $plain_output = new class() implements OutputCallbackInterface { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + public string $stdout = ''; + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + public string $stderr = ''; + + /** + * {@inheritdoc} + */ + public function __invoke(OutputTypeEnum $type, string $buffer): void { + if ($type === OutputTypeEnum::OUT) { + $this->stdout .= $buffer; + } + elseif ($type === OutputTypeEnum::ERR) { + $this->stderr .= $buffer; + } + } + + /** + * {@inheritdoc} + */ + public function clearErrorOutput(): void { + throw new \LogicException("Unexpected call to clearErrorOutput()."); + } + + /** + * {@inheritdoc} + */ + public function clearOutput(): void { + throw new \LogicException("Unexpected call to clearOutput()."); + } + + /** + * {@inheritdoc} + */ + public function getErrorOutput(): array { + throw new \LogicException("Unexpected call to getErrorOutput()."); + } + + /** + * {@inheritdoc} + */ + public function getOutput(): array { + throw new \LogicException("Unexpected call to getOutput()."); + } + + }; + /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */ + $runner = \Drupal::service(ComposerProcessRunnerInterface::class); + $command_options[] = "--working-dir={$this->dir}"; + $runner->run($command_options, callback: $plain_output); + return $plain_output; + } + + /** + * Transform the received $package into options for `composer init`. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * + * @return array + * The corresponding `composer init` options. + */ + private static function getComposerInitOptionsForPackage(array $package): array { + return array_filter(array_map(function ($k, $v) { + switch ($k) { + case 'name': + case 'description': + case 'type': + return "--$k=$v"; + + case 'require': + case 'require-dev': + if (empty($v)) { + return NULL; + } + $requirements = array_map( + fn(string $req_package, string $req_version): string => "$req_package:$req_version", + array_keys($v), + array_values($v) + ); + return "--$k=" . implode(',', $requirements); + + case 'version': + // This gets set in the repository metadata itself. + return NULL; + + case 'extra': + // Cannot be set using `composer init`, only `composer config` can. + return NULL; + + default: + throw new \InvalidArgumentException($k); + } + }, array_keys($package), array_values($package))); + } + + /** + * Creates a path repo. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * @param string $repo_path + * The path at which to create a path repo for this package. + * @param string|null $original_repo_path + * If NULL: this is the first version of this package. Otherwise: a string + * containing the path repo to the first version of this package. This will + * be used to automatically inherit the same files (typically *.info.yml). + */ + private function createPathRepo(array $package, string $repo_path, ?string $original_repo_path): void { + $fs = new SymfonyFileSystem(); + if (is_dir($repo_path)) { + throw new \LogicException("A path repo already exists at $repo_path."); + } + // Create the repo if it does not exist. + $fs->mkdir($repo_path); + // Forks also get the original's additional files (e.g. *.info.yml files). + if ($original_repo_path) { + $fs->mirror($original_repo_path, $repo_path); + // composer.json will be freshly generated by `composer init` below. + $fs->remove($repo_path . '/composer.json'); + } + // Switch the working directory from project root to repo path. + $project_root_dir = $this->dir; + $this->dir = $repo_path; + // Create a composer.json file using `composer init`. + $this->runComposerCommand(['init', ...static::getComposerInitOptionsForPackage($package)]); + // Set the `extra` property in the generated composer.json file using + // `composer config`, because `composer init` does not support it. + foreach ($package['extra'] ?? [] as $extra_property => $extra_value) { + $this->runComposerCommand(['config', "extra.$extra_property", '--json', json_encode($extra_value, JSON_UNESCAPED_SLASHES)]); + } + // Restore the project root as the working directory. + $this->dir = $project_root_dir; + } + + /** + * Adds a path repository. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * + * @return string + * The repository path. + */ + private function addRepository(array $package): string { + $name = $package['name']; + $path_repo_base = \Drupal::state()->get(self::PATH_REPO_STATE_KEY); + $repo_path = "$path_repo_base/" . str_replace('/', '--', $name); + + // Determine if the given $package is a new package or a fork of an existing + // one (that means it's either the same version but with other metadata, or + // a new version with other metadata). Existing path repos are never + // modified, not even if the same version of a package is assigned other + // metadata. This allows always comparing with the original metadata. + $is_new_or_fork = !is_dir($repo_path) ? 'new' : 'fork'; + if ($is_new_or_fork === 'fork') { + $original_composer_json_path = $repo_path . DIRECTORY_SEPARATOR . 'composer.json'; + $original_repo_path = $repo_path; + $original_composer_json_data = json_decode(file_get_contents($original_composer_json_path), TRUE, flags: JSON_THROW_ON_ERROR); + $forked_composer_json_data = NestedArray::mergeDeep($original_composer_json_data, $package); + if ($original_composer_json_data === $forked_composer_json_data) { + throw new \LogicException(sprintf('Nothing is actually different in this fork of the package %s.', $package['name'])); + } + $package = $forked_composer_json_data; + $repo_path .= "--{$package['version']}"; + // Cannot create multiple forks with the same version. This is likely + // due to a test simulating a failed Stage::apply(). + if (!is_dir($repo_path)) { + $this->createPathRepo($package, $repo_path, $original_repo_path); + } + } + else { + $this->createPathRepo($package, $repo_path, NULL); + } + + // Add the package to the Composer repository defined for the site. + $packages_json = $this->dir . '/packages.json'; + $packages_data = file_get_contents($packages_json); + $packages_data = json_decode($packages_data, TRUE, flags: JSON_THROW_ON_ERROR); + + $version = $package['version']; + $package['dist'] = [ + 'type' => 'path', + 'url' => $repo_path, + ]; + $packages_data['packages'][$name][$version] = $package; + assert(file_put_contents($packages_json, json_encode($packages_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== FALSE); + + return $repo_path; + } + + /** + * Sets up the path repos at absolute paths. + */ + public function setUpRepos(): void { + $fs = new SymfonyFileSystem(); + $path_repo_base = \Drupal::state()->get(self::PATH_REPO_STATE_KEY); + if (empty($path_repo_base)) { + $path_repo_base = FileSystem::getOsTemporaryDirectory() . '/base-repo-' . microtime(TRUE) . rand(0, 10000); + \Drupal::state()->set(self::PATH_REPO_STATE_KEY, $path_repo_base); + // Copy the existing repos that were used to make the fixtures into the + // new folder. + $fs->mirror(__DIR__ . '/../../../fixtures/path_repos', $path_repo_base); + } + // Update all the repos in the composer.json file to point to the new + // repos at the absolute path. + $composer_json = file_get_contents($this->dir . '/packages.json'); + assert(file_put_contents($this->dir . '/packages.json', str_replace('../path_repos/', "$path_repo_base/", $composer_json)) !== FALSE); + $this->runComposerCommand(['update', '--lock']); + $this->runComposerCommand(['install']); + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php new file mode 100644 index 000000000000..6c77f6a92797 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * Process factory that always sets the COMPOSER_MIRROR_PATH_REPOS env variable. + * + * This is necessary because the fake_site fixture is built from a Composer-type + * repository, which will normally try to symlink packages which are installed + * from local directories, which in turn will break Package Manager because it + * does not support symlinks pointing outside the main code base. The + * COMPOSER_MIRROR_PATH_REPOS environment variable forces Composer to mirror, + * rather than symlink, local directories when installing packages. + * + * @see \Drupal\fixture_manipulator\FixtureManipulator::setUpRepos() + */ +final class ProcessFactory implements ProcessFactoryInterface { + + /** + * Constructs a ProcessFactory object. + * + * @param \PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface $decorated + * The decorated process factory service. + */ + public function __construct(private readonly ProcessFactoryInterface $decorated) {} + + /** + * {@inheritdoc} + */ + public function create(array $command, ?PathInterface $cwd = NULL, array $env = []): ProcessInterface { + $process = $this->decorated->create($command, $cwd, $env); + + $env = $process->getEnv(); + $env['COMPOSER_MIRROR_PATH_REPOS'] = '1'; + $process->setEnv($env); + return $process; + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php new file mode 100644 index 000000000000..b9fabada49e8 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A fixture manipulator service that commits changes after begin. + */ +final class StageFixtureManipulator extends FixtureManipulator implements BeginnerInterface { + + /** + * The state key to use. + */ + private const STATE_KEY = __CLASS__ . 'MANIPULATOR_ARGUMENTS'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private StateInterface $state; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\BeginnerInterface + */ + private BeginnerInterface $inner; + + /** + * Constructions a StageFixtureManipulator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $inner + * The decorated beginner service. + */ + public function __construct(StateInterface $state, BeginnerInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->inner->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout); + if ($this->getQueuedManipulationItems()) { + $this->doCommitChanges($stagingDir->absolute()); + } + } + + /** + * {@inheritdoc} + */ + public function commitChanges(string $dir): void { + throw new \BadMethodCallException('::commitChanges() should not be called directly in StageFixtureManipulator().'); + } + + /** + * {@inheritdoc} + */ + public function __destruct() { + // Overrides `__destruct` because the staged fixture manipulator service + // will be destroyed after every request. + // @see \Drupal\fixture_manipulator\StageFixtureManipulator::handleTearDown() + } + + /** + * Handles test tear down to ensure all changes were committed. + */ + public static function handleTearDown(): void { + if (!empty(\Drupal::state()->get(self::STATE_KEY))) { + throw new \LogicException('The StageFixtureManipulator has arguments that were not cleared. This likely means that the PostCreateEvent was never fired.'); + } + } + + /** + * {@inheritdoc} + */ + protected function queueManipulation(string $method, array $arguments): void { + $stored_arguments = $this->getQueuedManipulationItems(); + $stored_arguments[$method][] = $arguments; + $this->state->set(self::STATE_KEY, $stored_arguments); + } + + /** + * {@inheritdoc} + */ + protected function clearQueuedManipulationItems(): void { + $this->state->delete(self::STATE_KEY); + } + + /** + * {@inheritdoc} + */ + protected function getQueuedManipulationItems(): array { + return $this->state->get(self::STATE_KEY, []); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml new file mode 100644 index 000000000000..316afcbb45f2 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml @@ -0,0 +1,7 @@ +name: 'Package Manager Bypass' +description: 'Mocks PathLocator service, decorates Beginner & Committer services (adds logging) and by default bypasses the Stager service (to minimize I/O during tests).' +type: module +package: Testing +dependencies: + - auto_updates:package_manager + - auto_updates:fixture_manipulator diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml new file mode 100644 index 000000000000..b5fea63ec720 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + Drupal\package_manager_bypass\LoggingBeginner: + decorates: 'PhpTuf\ComposerStager\API\Core\BeginnerInterface' + Drupal\package_manager_bypass\LoggingCommitter: + decorates: 'PhpTuf\ComposerStager\API\Core\CommitterInterface' diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php new file mode 100644 index 000000000000..efb8a10759f9 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +/** + * Trait to make Composer Stager throw pre-determined exceptions in tests. + * + * @internal + */ +trait ComposerStagerExceptionTrait { + + /** + * Sets an exception to be thrown. + * + * @param string|null $class + * The class of exception to throw, or NULL to delete a stored exception. + * @param mixed ...$arguments + * Arguments to pass to the exception constructor. + */ + public static function setException(?string $class = \Exception::class, mixed ...$arguments): void { + if ($class) { + \Drupal::state()->set(static::class . '-exception', func_get_args()); + } + else { + \Drupal::state()->delete(static::class . '-exception'); + } + } + + /** + * Throws the exception if set. + */ + private function throwExceptionIfSet(): void { + if ($exception = $this->state->get(static::class . '-exception')) { + $class = array_shift($exception); + throw new $class(...$exception); + } + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php new file mode 100644 index 000000000000..6bb75ed5be9e --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Beginner decorator that adds logging. + * + * @internal + */ +final class LoggingBeginner implements BeginnerInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\BeginnerInterface + */ + private $inner; + + /** + * Constructs a Beginner object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $inner + * The decorated beginner service. + */ + public function __construct(StateInterface $state, BeginnerInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions?->getAll(), $timeout); + $this->throwExceptionIfSet(); + $this->inner->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php new file mode 100644 index 000000000000..4d5e1e160ca4 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Committer decorator that adds logging. + * + * @internal + */ +final class LoggingCommitter implements CommitterInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\CommitterInterface + */ + private $inner; + + /** + * Constructs a Committer object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $inner + * The decorated committer service. + */ + public function __construct(StateInterface $state, CommitterInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($stagingDir, $activeDir, $exclusions?->getAll(), $timeout); + $this->throwExceptionIfSet(); + $this->inner->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php new file mode 100644 index 000000000000..2bbbad5c774f --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +/** + * Records information about method invocations. + * + * This can be used by functional tests to ensure that the bypassed Composer + * Stager services were called as expected. Kernel and unit tests should use + * regular mocks instead. + * + * @internal + */ +trait LoggingDecoratorTrait { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Returns the arguments from every invocation of the main class method. + * + * @return mixed[] + * The arguments from every invocation of the main class method. + */ + public function getInvocationArguments(): array { + return $this->state->get(static::class . ' arguments', []); + } + + /** + * Records the arguments from an invocation of the main class method. + * + * @param mixed ...$arguments + * The arguments that the main class method was called with. + */ + private function saveInvocationArguments(...$arguments): void { + $invocations = $this->getInvocationArguments(); + $invocations[] = $arguments; + $this->state->set(static::class . ' arguments', $invocations); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php new file mode 100644 index 000000000000..dcdd6d262b62 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\PathLocator as BasePathLocator; +use Symfony\Component\Filesystem\Path; + +/** + * Mock path locator: allows specifying paths instead of discovering paths. + * + * @internal + */ +final class MockPathLocator extends BasePathLocator { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Constructs a PathLocator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param mixed ...$arguments + * Additional arguments to pass to the parent constructor. + */ + public function __construct(StateInterface $state, ...$arguments) { + parent::__construct(...$arguments); + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public function getProjectRoot(): string { + $project_root = $this->state->get(static::class . ' root'); + if ($project_root === NULL) { + $project_root = $this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..'; + $project_root = realpath($project_root); + } + return $project_root; + } + + /** + * {@inheritdoc} + */ + public function getVendorDirectory(): string { + return $this->state->get(static::class . ' vendor', parent::getVendorDirectory()); + } + + /** + * {@inheritdoc} + */ + public function getWebRoot(): string { + return $this->state->get(static::class . ' web', parent::getWebRoot()); + } + + /** + * {@inheritdoc} + */ + public function getStagingRoot(): string { + return $this->state->get(static::class . ' stage', parent::getStagingRoot()); + } + + /** + * Sets the paths to return. + * + * @param string|null $project_root + * The project root, or NULL to defer to the parent class. + * @param string|null $vendor_dir + * The vendor directory, or NULL to defer to the parent class. + * @param string|null $web_root + * The web root, relative to the project root, or NULL to defer to the + * parent class. + * @param string|null $staging_root + * The absolute path of the stage root directory, or NULL to defer to the + * parent class. + */ + public function setPaths(?string $project_root, ?string $vendor_dir, ?string $web_root, ?string $staging_root): void { + foreach ([$project_root, $staging_root] as $path) { + if (!empty($path) && !Path::isAbsolute($path)) { + throw new \InvalidArgumentException('project_root and staging_root need to be absolute paths.'); + } + } + $this->state->set(static::class . ' root', is_null($project_root) ? NULL : realpath($project_root)); + $this->state->set(static::class . ' vendor', is_null($vendor_dir) ? NULL : realpath($vendor_dir)); + $this->state->set(static::class . ' web', is_null($web_root) ? NULL : Path::canonicalize($web_root)); + $this->state->set(static::class . ' stage', is_null($staging_root) ? NULL : realpath($staging_root)); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php new file mode 100644 index 000000000000..fd15f9fbc517 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Composer\Json\JsonFile; +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Stager implementation that does nothing, except logging. + * + * By default, it will modify composer.lock in the stage directory, to fool the + * \Drupal\package_manager\Validator\LockFileValidator into thinking that there + * are pending composer operations. + * + * Opt out of this by calling @code setLockFileShouldChange(FALSE) @endcode. + * + * @see ::setLockFileShouldChange() + * @see \Drupal\package_manager\Validator\LockFileValidator + * + * @internal + */ +final class NoOpStager implements StagerInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * Constructs a Stager object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(StateInterface $state) { + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public function stage(array $composerCommand, PathInterface $activeDir, PathInterface $stagingDir, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($composerCommand, $stagingDir, $timeout); + $this->throwExceptionIfSet(); + + // If desired, simulate a change to the lock file (e.g., as a result of + // running `composer update`). + $lockFile = new JsonFile($stagingDir->absolute() . '/composer.lock'); + $changeLockFile = $this->state->get(static::class . ' lock', TRUE); + + if ($changeLockFile && $lockFile->exists()) { + $data = $lockFile->read(); + $data['_time'] = microtime(); + $lockFile->write($data); + } + } + + /** + * Sets whether ::stage() should simulate a change in the lock file. + * + * @param bool $value + * (optional) Whether to simulate a change in the lock file when + * ::stage() is called. Defaults to TRUE. + */ + public static function setLockFileShouldChange(bool $value = TRUE): void { + \Drupal::state()->set(static::class . ' lock', $value); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php new file mode 100644 index 000000000000..38a55eac4e68 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\Core\Site\Settings; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use Symfony\Component\DependencyInjection\Parameter; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Defines services to bypass Package Manager's core functionality. + * + * @internal + */ +final class PackageManagerBypassServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container): void { + parent::alter($container); + + // By default, \Drupal\package_manager_bypass\NoOpStager is applied, except + // when a test opts out by setting this setting to FALSE. + // @see \Drupal\package_manager_bypass\NoOpStager::setLockFileShouldChange() + if (Settings::get('package_manager_bypass_composer_stager', TRUE)) { + $container->register(NoOpStager::class) + ->setClass(NoOpStager::class) + ->setPublic(FALSE) + ->setAutowired(TRUE) + ->setDecoratedService(StagerInterface::class); + } + + $container->getDefinition(PathLocator::class) + ->setClass(MockPathLocator::class) + ->setAutowired(FALSE) + ->setArguments([ + new Reference('state'), + new Parameter('app.root'), + new Reference('config.factory'), + new Reference('file_system'), + ]); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml new file mode 100644 index 000000000000..6a64061f1ced --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test API' +description: 'Provides API endpoints for doing stage operations in functional tests.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml new file mode 100644 index 000000000000..7911b8c057aa --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml @@ -0,0 +1,21 @@ +package_manager_test_api: + path: '/package-manager-test-api' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::run' + requirements: + _access: 'TRUE' +package_manager_test_api.finish: + path: '/package-manager-test-api/finish/{id}' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::finish' + requirements: + _access: 'TRUE' +package_manager_test_api.check_setup: + path: '/package-manager-test-api/check-setup' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::checkSetup' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE + no_cache: TRUE diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php new file mode 100644 index 000000000000..d2839be3947c --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_api; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Url; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Provides API endpoints to interact with a stage directory in functional test. + */ +class ApiController extends ControllerBase { + + /** + * The route to redirect to after the stage has been applied. + * + * @var string + */ + protected $finishedRoute = 'package_manager_test_api.finish'; + + public function __construct(protected StageBase $stage) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $stage = new ControllerStage( + $container->get(PathLocator::class), + $container->get(BeginnerInterface::class), + $container->get(StagerInterface::class), + $container->get(CommitterInterface::class), + $container->get(QueueFactory::class), + $container->get('event_dispatcher'), + $container->get('tempstore.shared'), + $container->get('datetime.time'), + $container->get(PathFactoryInterface::class), + $container->get(FailureMarker::class), + ); + return new static($stage); + } + + /** + * Begins a stage life cycle. + * + * Creates a stage directory, requires packages into it, applies changes to + * the active directory. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. The runtime and dev dependencies are expected to be in + * either the query string or request body, under the 'runtime' and 'dev' + * keys, respectively. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A response that directs to the ::finish() method. + * + * @see ::finish() + */ + public function run(Request $request): RedirectResponse { + $id = $this->createAndApplyStage($request); + $redirect_url = Url::fromRoute($this->finishedRoute) + ->setRouteParameter('id', $id) + ->setAbsolute() + ->toString(); + + return new RedirectResponse($redirect_url); + } + + /** + * Performs post-apply tasks and destroys the stage. + * + * @param string $id + * The stage ID. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function finish(string $id): Response { + $this->stage->claim($id)->postApply(); + $this->stage->destroy(); + return new Response(); + } + + /** + * Creates a stage, requires packages into it, and applies the changes. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. The runtime and dev dependencies are expected to be in + * either the query string or request body, under the 'runtime' and 'dev' + * keys, respectively. + * + * @return string + * Unique ID for the stage, which can be used to claim the stage before + * performing other operations on it. Calling code should store this ID for + * as long as the stage needs to exist. + */ + protected function createAndApplyStage(Request $request) : string { + $id = $this->stage->create(); + $this->stage->require( + $request->get('runtime', []), + $request->get('dev', []) + ); + $this->stage->apply(); + return $id; + } + + /** + * Returns the information about current PHP server used for build tests. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function checkSetup(): Response { + return new Response( + 'max_execution_time=' . ini_get('max_execution_time') . + ':set_time_limit-exists=' . (function_exists('set_time_limit') ? 'yes' : 'no') + ); + } + +} + +/** + * Non-abstract version of StageBase. + * + * This is needed because we cannot instantiate StageBase as it's abstract, and + * we also can't use anonymous class because the name of anonymous class is + * always unique for every request which will create problem while claiming the + * stage as the stored lock will be different from current lock. + * + * @see \Drupal\package_manager\StageBase::claim() + */ +final class ControllerStage extends StageBase { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager_test_api:controller'; + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml new file mode 100644 index 000000000000..2366f4585f3f --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test Event Logger' +description: 'Provides an event subscriber to test logging during events in Package Manager' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml new file mode 100644 index 000000000000..408eba84e496 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml @@ -0,0 +1,5 @@ +services: + package_manager_test_event_logger.subscriber: + class: Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php new file mode 100644 index 000000000000..295394ae6320 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_event_logger\EventSubscriber; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber to test logging during events in Package Manager. + */ +final class EventLogSubscriber implements EventSubscriberInterface { + + /** + * The name of the log file to write to. + * + * @var string + */ + public const LOG_FILE_NAME = 'package_manager_test_event.log'; + + /** + * Excludes the log file from Package Manager operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event being handled. + */ + public function excludeLogFile(CollectPathsToExcludeEvent $event): void { + $event->addPathsRelativeToProjectRoot([self::LOG_FILE_NAME]); + } + + /** + * Logs all events in the stage life cycle. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function logEventInfo(StageEvent $event): void { + $log_file = \Drupal::service(PathLocator::class)->getProjectRoot() . '/' . self::LOG_FILE_NAME; + + if (file_exists($log_file)) { + $log_data = file_get_contents($log_file); + $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR); + } + else { + $log_data = []; + } + + $log_data[] = [ + 'event' => $event::class, + 'stage' => $event->stage::class, + ]; + file_put_contents($log_file, json_encode($log_data, JSON_UNESCAPED_SLASHES)); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // This subscriber should run before every other validator, because the + // purpose of this subscriber is to log all dispatched events. + // @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + // @see \Drupal\package_manager\Validator\BaseRequirementValidatorTrait + // @see \Drupal\package_manager\Validator\EnvironmentSupportValidator + return [ + CollectPathsToExcludeEvent::class => ['excludeLogFile'], + PreCreateEvent::class => ['logEventInfo', PHP_INT_MAX], + PostCreateEvent::class => ['logEventInfo', PHP_INT_MAX], + PreRequireEvent::class => ['logEventInfo', PHP_INT_MAX], + PostRequireEvent::class => ['logEventInfo', PHP_INT_MAX], + PreApplyEvent::class => ['logEventInfo', PHP_INT_MAX], + PostApplyEvent::class => ['logEventInfo', PHP_INT_MAX], + ]; + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml new file mode 100644 index 000000000000..ecf8c8e6122e --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml @@ -0,0 +1,7 @@ +name: 'Package Manager Test Release history' +type: module +description: 'Provides a mechanism for serving fake release history metadata in functional tests.' +package: Testing +dependencies: + - drupal:update + - drupal:update_test diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml new file mode 100644 index 000000000000..df0b40c61781 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml @@ -0,0 +1,9 @@ +package_manager_test_release_history.metadata: + path: '/test-release-history/{project_name}/{version}' + defaults: + _title: 'Update test' + _controller: '\Drupal\package_manager_test_release_history\TestController::metadata' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php new file mode 100644 index 000000000000..1c99bb6e192b --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_release_history; + +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response; + +class TestController extends ControllerBase { + + /** + * Page callback: Prints mock XML for the Update Manager module. + * + * @todo This is a wholesale copy of + * \Drupal\update_test\Controller\UpdateTestController::updateTest() for + * testing package_manager. This was done in order to use a different + * directory of mock XML files. Remove this module in + * https://drupal.org/i/3274826. + */ + public function metadata($project_name = 'drupal', $version = NULL): Response { + $xml_map = $this->config('update_test.settings')->get('xml_map'); + if (isset($xml_map[$project_name])) { + $file = $xml_map[$project_name]; + } + elseif (isset($xml_map['#all'])) { + $file = $xml_map['#all']; + } + else { + // The test didn't specify, for example, the webroot has other modules and + // themes installed but they're disabled by the version of the site + // running the test. So, we default to a file we know won't exist, so at + // least we'll get an empty xml response instead of a bunch of Drupal page + // output. + $file = '#broken#'; + } + + $headers = ['Content-Type' => 'text/xml; charset=utf-8']; + if (!is_file($file)) { + // Return an empty response. + return new Response('', 200, $headers); + } + return new BinaryFileResponse($file, 200, $headers); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml new file mode 100644 index 000000000000..c245910b786c --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml @@ -0,0 +1,4 @@ +name: 'Package Manager Test Update' +description: 'A module to test updates' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml new file mode 100644 index 000000000000..3558b4cd3c88 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Validation Test' +description: 'Provides an event subscriber to test Package Manager validation.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml new file mode 100644 index 000000000000..bd41327d3159 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml @@ -0,0 +1,12 @@ +services: + package_manager_test_validation.subscriber: + class: Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber + arguments: + - '@state' + tags: + - { name: event_subscriber } + package_manager.validator.collect_paths_to_exclude_fail: + class: Drupal\package_manager_test_validation\CollectPathsToExcludeFailValidator + autowire: true + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php new file mode 100644 index 000000000000..3a920d927f3c --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Allows to test an excluder which fails on CollectPathsToExcludeEvent. + */ +class CollectPathsToExcludeFailValidator implements EventSubscriberInterface { + + /** + * Constructs a CollectPathsToExcludeFailValidator object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'callToComposer', + ]; + } + + /** + * Fails when composer.json is deleted to simulate failure on excluders. + */ + public function callToComposer(): void { + $this->composerInspector->validate($this->pathLocator->getProjectRoot()); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php new file mode 100644 index 000000000000..53e98202ab74 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation\EventSubscriber; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber for testing validation of Package Manager events. + */ +class TestSubscriber implements EventSubscriberInterface { + + /** + * The key to use store the test results. + * + * @var string + */ + protected const STATE_KEY = 'package_manager_test_validation'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * Creates a TestSubscriber object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(StateInterface $state) { + $this->state = $state; + } + + /** + * Sets whether a specific event will call exit(). + * + * This is useful for simulating an unrecoverable (fatal) error when handling + * the given event. + * + * @param string $event + * The event class. + */ + public static function setExit(string $event): void { + \Drupal::state()->set(self::getStateKey($event), 'exit'); + } + + /** + * Sets validation results for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Drupal\package_manager\ValidationResult[]|null $results + * The validation results, or NULL to delete stored results. + * @param string $event + * The event class. + */ + public static function setTestResult(?array $results, string $event): void { + $key = static::getStateKey($event); + + $state = \Drupal::state(); + if (isset($results)) { + $state->set($key, $results); + } + else { + $state->delete($key); + } + } + + /** + * Sets an exception to throw for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Throwable|null $error + * The exception to throw, or NULL to delete a stored exception. + * @param string $event + * The event class. + */ + public static function setException(?\Throwable $error, string $event): void { + $key = self::getStateKey($event); + + $state = \Drupal::state(); + if (isset($error)) { + $state->set($key, $error); + } + else { + $state->delete($key); + } + } + + /** + * Computes the state key to use for a given event class. + * + * @param string $event + * The event class. + * + * @return string + * The state key under which to store the results for the given event. + */ + protected static function getStateKey(string $event): string { + $key = hash('sha256', static::class . $event); + return static::STATE_KEY . substr($key, 0, 8); + } + + /** + * Adds validation results to a stage event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function handleEvent(StageEvent $event): void { + $results = $this->state->get(self::getStateKey(get_class($event)), []); + + // Record that value of maintenance mode for each event. + $this->state->set(get_class($event) . '.' . 'system.maintenance_mode', $this->state->get('system.maintenance_mode')); + + if ($results instanceof \Throwable) { + throw $results; + } + elseif ($results === 'exit') { + exit(); + } + elseif (is_string($results)) { + \Drupal::messenger()->addStatus($results); + return; + } + /** @var \Drupal\package_manager\ValidationResult $result */ + foreach ($results as $result) { + $event->addResult($result); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => ['handleEvent', 5], + PostCreateEvent::class => ['handleEvent', 5], + PreRequireEvent::class => ['handleEvent', 5], + PostRequireEvent::class => ['handleEvent', 5], + PreApplyEvent::class => ['handleEvent', 5], + PostApplyEvent::class => ['handleEvent', 5], + StatusCheckEvent::class => ['handleEvent', 5], + ]; + } + + /** + * Sets a status message that will be sent to the messenger for an event. + * + * @param string $message + * Message text. + * @param string $event + * The event class. + */ + public static function setMessage(string $message, string $event): void { + $key = static::getStateKey($event); + $state = \Drupal::state(); + $state->set($key, $message); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php new file mode 100644 index 000000000000..dd8fea39a4d5 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\package_manager\Validator\StagedDBUpdateValidator; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Modifies container services for testing. + */ +class PackageManagerTestValidationServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container): void { + parent::alter($container); + + $service_id = StagedDBUpdateValidator::class; + if ($container->hasDefinition($service_id)) { + $container->getDefinition($service_id) + ->setClass(StagedDatabaseUpdateValidator::class) + ->addMethodCall('setState', [ + new Reference('state'), + ]); + } + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php new file mode 100644 index 000000000000..cbab1d42b070 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\package_manager\Validator\StagedDBUpdateValidator as BaseValidator; +use Drupal\Core\Extension\Extension; +use Drupal\Core\State\StateInterface; + +/** + * Allows tests to dictate which extensions have staged database updates. + */ +class StagedDatabaseUpdateValidator extends BaseValidator { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Sets the state service dependency. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function setState(StateInterface $state): void { + $this->state = $state; + } + + /** + * Sets the names of the extensions which should have staged database updates. + * + * @param string[]|null $extensions + * The machine names of the extensions which should say they have staged + * database updates, or NULL to defer to the parent class. + */ + public static function setExtensionsWithUpdates(?array $extensions): void { + \Drupal::state()->set(static::class, $extensions); + } + + /** + * {@inheritdoc} + */ + public function hasStagedUpdates(string $stage_dir, Extension $extension): bool { + $extensions = $this->state->get(static::class); + if (isset($extensions)) { + return in_array($extension->getName(), $extensions, TRUE); + } + return parent::hasStagedUpdates($stage_dir, $extension); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php new file mode 100644 index 000000000000..dd9f653133ee --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +/** + * Tests installing packages in a stage directory. + * + * @group package_manager + * @internal + */ +class PackageInstallTest extends TemplateProjectTestBase { + + /** + * Tests installing packages in a stage directory. + */ + public function testPackageInstall(): void { + $this->createTestProject('RecommendedProject'); + + $this->setReleaseMetadata([ + 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', + ]); + $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0')); + // Repository definitions affect the lock file hash, so update the hash to + // ensure that Composer won't complain that the lock file is out of sync. + $this->runComposer('composer update --lock', 'project'); + + // Use the API endpoint to create a stage and install alpha 1.0.0. + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/alpha:1.0.0', + ], + ] + ); + // Assert the module was installed. + $this->assertFileEquals( + __DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0/composer.json', + $this->getWebRoot() . '/modules/contrib/alpha/composer.json', + ); + $this->assertRequestedChangesWereLogged(['Install drupal/alpha 1.0.0']); + $this->assertAppliedChangesWereLogged(['Installed drupal/alpha 1.0.0']); + } + + /** + * Tests installing a Drupal submodule. + * + * This test installs a submodule using a set-up that mimics how + * packages.drupal.org handles submodules. Submodules are Composer + * metapackages which depend on the Composer package of the main module. + */ + public function testSubModules(): void { + $this->createTestProject('RecommendedProject'); + // Set up the release metadata for the main module. The submodule does not + // have its own release metadata. + $this->setReleaseMetadata([ + 'main_module' => __DIR__ . '/../../fixtures/release-history/main_module.1.0.0.xml', + ]); + + // Add repositories for Drupal modules which will contain the code for its + // submodule also. + $this->addRepository('main_module', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/build_test_projects/main_module')); + + // Add a repository for the submodule of 'main_module'. Submodule + // repositories are metapackages which have no code of their own but that + // require the main module. + $this->addRepository('main_module_submodule', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/path_repos/main_module_submodule')); + + // Repository definitions affect the lock file hash, so update the hash to + // ensure that Composer won't complain that the lock file is out of sync. + $this->runComposer('composer update --lock', 'project'); + + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/main_module_submodule:1.0.0', + ], + ] + ); + + // Assert main_module and the submodule were installed. + $main_module_path = $this->getWebRoot() . '/modules/contrib/main_module'; + $this->assertFileExists("$main_module_path/main_module.info.yml"); + $this->assertFileExists("$main_module_path/main_module_submodule/main_module_submodule.info.yml"); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php new file mode 100644 index 000000000000..3202b3eb2e00 --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +use Drupal\package_manager_test_api\ControllerStage; + +/** + * Tests updating packages in a stage directory. + * + * @group package_manager + * @internal + */ +class PackageUpdateTest extends TemplateProjectTestBase { + + /** + * Tests updating packages in a stage directory. + */ + public function testPackageUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $fixtures = __DIR__ . '/../../fixtures/build_test_projects'; + + $alpha_repo_path = $this->copyFixtureToTempDirectory("$fixtures/alpha/1.0.0"); + $this->addRepository('alpha', $alpha_repo_path); + $updated_module_repo_path = $this->copyFixtureToTempDirectory("$fixtures/updated_module/1.0.0"); + $this->addRepository('updated_module', $updated_module_repo_path); + $this->setReleaseMetadata([ + 'updated_module' => __DIR__ . '/../../fixtures/release-history/updated_module.1.1.0.xml', + ]); + $this->runComposer('composer require drupal/alpha drupal/updated_module --update-with-all-dependencies', 'project'); + + // The updated_module provides actual Drupal-facing functionality that we're + // testing as well, so we need to install it. + $this->installModules(['updated_module']); + + // Change both modules' upstream version. + static::copyFixtureFilesTo("$fixtures/alpha/1.1.0", $alpha_repo_path); + static::copyFixtureFilesTo("$fixtures/updated_module/1.1.0", $updated_module_repo_path); + // Make .git folder + + // Use the API endpoint to create a stage and update updated_module to + // 1.1.0. Even though both modules have version 1.1.0 available, only + // updated_module should be updated. + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/updated_module:1.1.0', + ], + ] + ); + + $expected_versions = [ + 'alpha' => '1.0.0', + 'updated_module' => '1.1.0', + ]; + foreach ($expected_versions as $module_name => $expected_version) { + $path = "/modules/contrib/$module_name/composer.json"; + $module_composer_json = json_decode(file_get_contents($this->getWebRoot() . "/$path")); + $this->assertSame($expected_version, $module_composer_json?->version); + } + // The post-apply event subscriber in updated_module 1.1.0 should have + // created this file. + // @see \Drupal\updated_module\PostApplySubscriber::postApply() + $this->assertSame('Bravo!', file_get_contents($this->getWorkspaceDirectory() . '/project/bravo.txt')); + + $this->assertExpectedStageEventsFired(ControllerStage::class); + $this->assertRequestedChangesWereLogged(['Update drupal/updated_module from 1.0.0 to 1.1.0']); + $this->assertAppliedChangesWereLogged(['Updated drupal/updated_module from 1.0.0 to 1.1.0']); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php new file mode 100644 index 000000000000..c2aede3beeed --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -0,0 +1,733 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; +use Drupal\BuildTests\QuickStart\QuickStartTestBase; +use Drupal\Composer\Composer; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber; +use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +use Drupal\Tests\RandomGeneratorTrait; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Base class for tests which create a test site from a core project template. + * + * The test site will be created from one of the core Composer project templates + * (drupal/recommended-project or drupal/legacy-project) and contain complete + * copies of Drupal core and all installed dependencies, completely independent + * of the currently running code base. + * + * @internal + */ +abstract class TemplateProjectTestBase extends QuickStartTestBase { + + use AssertPreconditionsTrait; + use FixtureUtilityTrait; + use RandomGeneratorTrait; + + /** + * The web root of the test site, relative to the workspace directory. + * + * @var string + */ + private string $webRoot; + + /** + * A secondary server instance, to serve XML metadata about available updates. + * + * @var \Symfony\Component\Process\Process + */ + private Process $metadataServer; + + /** + * All output that the PHP web server logs to the error buffer. + * + * @var string + */ + private string $serverErrorLog = ''; + + /** + * The PHP web server's max_execution_time value. + * + * @var int + */ + protected const MAX_EXECUTION_TIME = 20; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // Build tests cannot be run if SQLite minimum version is not met. + $minimum_version = Tasks::SQLITE_MINIMUM_VERSION; + $actual_version = (new \PDO('sqlite::memory:')) + ->query('select sqlite_version()') + ->fetch()[0]; + if (version_compare($actual_version, $minimum_version, '<')) { + $this->markTestSkipped("SQLite version $minimum_version or later is required, but $actual_version was detected."); + } + + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + $this->metadataServer?->stop(); + parent::tearDown(); + } + + /** + * Data provider for tests which use all the core project templates. + * + * @return string[][] + * The test cases. + */ + public static function providerTemplate(): array { + return [ + 'RecommendedProject' => ['RecommendedProject'], + 'LegacyProject' => ['LegacyProject'], + ]; + } + + /** + * Sets the version of Drupal core to which the test site will be updated. + * + * @param string $version + * The Drupal core version to set. + */ + protected function setUpstreamCoreVersion(string $version): void { + $this->createVendorRepository([ + 'drupal/core' => $version, + 'drupal/core-dev' => $version, + 'drupal/core-dev-pinned' => $version, + 'drupal/core-recommended' => $version, + 'drupal/core-composer-scaffold' => $version, + 'drupal/core-project-message' => $version, + 'drupal/core-vendor-hardening' => $version, + ]); + + // Change the \Drupal::VERSION constant and put placeholder text in the + // README so we can ensure that we really updated to the correct version. We + // also change the default site configuration files so we can ensure that + // these are updated as well, despite `sites/default` being write-protected. + // @see ::assertUpdateSuccessful() + // @see ::createTestProject() + $core_dir = $this->getWorkspaceDrupalRoot() . '/core'; + Composer::setDrupalVersion($this->getWorkspaceDrupalRoot(), $version); + file_put_contents("$core_dir/README.txt", "Placeholder for Drupal core $version."); + + foreach (['default.settings.php', 'default.services.yml'] as $file) { + file_put_contents("$core_dir/assets/scaffold/files/$file", "# This is part of Drupal $version.\n", FILE_APPEND); + } + } + + /** + * Returns the full path to the test site's document root. + * + * @return string + * The full path of the test site's document root. + */ + protected function getWebRoot(): string { + return $this->getWorkspaceDirectory() . '/' . $this->webRoot; + } + + /** + * {@inheritdoc} + */ + protected function instantiateServer($port, $working_dir = NULL) { + $working_dir = $working_dir ?: $this->webRoot; + $finder = new PhpExecutableFinder(); + $working_path = $this->getWorkingPath($working_dir); + $server = [ + $finder->find(), + '-S', + '127.0.0.1:' . $port, + '-d max_execution_time=' . static::MAX_EXECUTION_TIME, + '-d disable_functions=set_time_limit', + '-t', + $working_path, + ]; + if (file_exists($working_path . DIRECTORY_SEPARATOR . '.ht.router.php')) { + $server[] = $working_path . DIRECTORY_SEPARATOR . '.ht.router.php'; + } + $ps = new Process($server, $working_path); + $ps->setIdleTimeout(30) + ->setTimeout(30) + ->start(function ($output_type, $output): void { + if ($output_type === Process::ERR) { + $this->serverErrorLog .= $output; + } + }); + // Wait until the web server has started. It is started if the port is no + // longer available. + for ($i = 0; $i < 50; $i++) { + usleep(100000); + if (!$this->checkPortIsAvailable($port)) { + return $ps; + } + } + + throw new \RuntimeException(sprintf("Unable to start the web server.\nCMD: %s \nCODE: %d\nSTATUS: %s\nOUTPUT:\n%s\n\nERROR OUTPUT:\n%s", $ps->getCommandLine(), $ps->getExitCode(), $ps->getStatus(), $ps->getOutput(), $ps->getErrorOutput())); + } + + /** + * {@inheritdoc} + */ + public function installQuickStart($profile, $working_dir = NULL): void { + parent::installQuickStart("$profile --no-ansi", $working_dir ?: $this->webRoot); + + // Always allow test modules to be installed in the UI and, for easier + // debugging, always display errors in their dubious glory. + $php = <<<END +\$settings['extension_discovery_scan_tests'] = TRUE; +\$config['system.logging']['error_level'] = 'verbose'; +END; + $this->writeSettings($php); + } + + /** + * {@inheritdoc} + */ + public function visit($request_uri = '/', $working_dir = NULL) { + return parent::visit($request_uri, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function formLogin($username, $password, $working_dir = NULL): void { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + + /** + * Adds a path repository to the test site. + * + * @param string $name + * An arbitrary name for the repository. + * @param string $path + * The path of the repository. Must exist in the file system. + * @param string $working_directory + * (optional) The Composer working directory. Defaults to 'project'. + */ + protected function addRepository(string $name, string $path, $working_directory = 'project'): void { + $this->assertDirectoryExists($path); + + $repository = json_encode([ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ], JSON_UNESCAPED_SLASHES); + $this->runComposer("composer config repo.$name '$repository'", $working_directory); + } + + /** + * Prepares the test site to serve an XML feed of available release metadata. + * + * @param array $xml_map + * The update XML map, as used by update_test.settings. + * + * @see \Drupal\package_manager_test_release_history\TestController::metadata() + */ + protected function setReleaseMetadata(array $xml_map): void { + foreach ($xml_map as $metadata_file) { + $this->assertFileIsReadable($metadata_file); + } + $xml_map = var_export($xml_map, TRUE); + $this->writeSettings("\$config['update_test.settings']['xml_map'] = $xml_map;"); + } + + /** + * Creates a test project from a given template and installs Drupal. + * + * @param string $template + * The template to use. Can be 'RecommendedProject' or 'LegacyProject'. + */ + protected function createTestProject(string $template): void { + // Create a copy of core (including its Composer plugins, templates, and + // metapackages) which we can modify. + $this->copyCodebase(); + + $workspace_dir = $this->getWorkspaceDirectory(); + $project_dir = $workspace_dir . '/project'; + mkdir($project_dir); + + $data = file_get_contents("$workspace_dir/composer/Template/$template/composer.json"); + $data = json_decode($data, TRUE, flags: JSON_THROW_ON_ERROR); + + // Allow pre-release versions of dependencies. + $data['minimum-stability'] = 'dev'; + + // Remove all repositories and replace them with a single local one that + // provides all dependencies. + $data['repositories'] = [ + 'vendor' => [ + 'type' => 'composer', + 'url' => 'file://' . $workspace_dir . '/vendor.json', + ], + // Disable Packagist entirely so that we don't test the Internet. + 'packagist.org' => FALSE, + ]; + + // Allow any version of the Drupal core packages in the template project. + self::unboundCoreConstraints($data['require']); + self::unboundCoreConstraints($data['require-dev']); + + // Do not run development Composer plugin, since it tries to run an + // executable that might not exist while dependencies are being installed. + // It adds no value to this test. + $data['config']['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer'] = FALSE; + + // Always force Composer to mirror path repositories. This is necessary + // because dependencies are installed from a Composer-type repository, which + // will normally try to symlink packages which are installed from local + // directories. This breaks Package Manager, because it does not support + // symlinks pointing outside the main code base. + $script = '@putenv COMPOSER_MIRROR_PATH_REPOS=1'; + $data['scripts']['pre-install-cmd'] = $script; + $data['scripts']['pre-update-cmd'] = $script; + + file_put_contents($project_dir . '/composer.json', json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + // Because we set the COMPOSER_MIRROR_PATH_REPOS=1 environment variable when + // creating the project, none of the dependencies should be symlinked. + $this->assertStringNotContainsString('Symlinking', $this->runComposer('composer install', 'project')); + + // If using the drupal/recommended-project template, we don't expect there + // to be an .htaccess file at the project root. One would normally be + // generated by Composer when Package Manager or other code creates a + // ComposerInspector object in the active directory, except that Package + // Manager takes specific steps to prevent that. So, here we're just + // confirming that, in fact, Composer's .htaccess protection was disabled. + // We don't do this for the drupal/legacy-project template because its + // project root, which is also the document root, SHOULD contain a .htaccess + // generated by Drupal core. + // We do this check because this test uses PHP's built-in web server, which + // ignores .htaccess files and everything in them, so a Composer-generated + // .htaccess file won't cause this test to fail. + if ($template === 'RecommendedProject') { + $this->assertFileDoesNotExist("$workspace_dir/project/.htaccess"); + } + + // Now that we know the project was created successfully, we can set the + // web root with confidence. + $this->webRoot = 'project/' . $data['extra']['drupal-scaffold']['locations']['web-root']; + + // Install Drupal. + $this->installQuickStart('standard'); + $this->formLogin($this->adminUsername, $this->adminPassword); + + // When checking for updates, we need to be able to make sub-requests, but + // the built-in PHP server is single-threaded. Therefore, open a second + // server instance on another port, which will serve the metadata about + // available updates. + $port = $this->findAvailablePort(); + $this->metadataServer = $this->instantiateServer($port); + $code = <<<END +\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history'; +END; + + // Ensure Package Manager logs Composer Stager's process output to a file + // named for the current test. + $log = $this->getDrupalRoot() . '/sites/simpletest/browser_output'; + @mkdir($log, recursive: TRUE); + $this->assertDirectoryIsWritable($log); + $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name(); + if ($this->usesDataProvider()) { + $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName()); + } + $code .= <<<END +\$config['package_manager.settings']['log'] = '$log-package_manager.log'; +END; + + $this->writeSettings($code); + + // Install helpful modules. + $this->installModules([ + 'package_manager_test_api', + 'package_manager_test_event_logger', + 'package_manager_test_release_history', + ]); + + // Confirm the server time out settings. + // @see \Drupal\Tests\package_manager\Build\TemplateProjectTestBase::instantiateServer() + $this->visit('/package-manager-test-api/check-setup'); + $this->getMink() + ->assertSession() + ->pageTextContains("max_execution_time=" . static::MAX_EXECUTION_TIME . ":set_time_limit-exists=no"); + } + + /** + * Changes constraints for core packages to `*`. + * + * @param string[] $constraints + * A set of version constraints, like you'd find in the `require` or + * `require-dev` sections of `composer.json`. This array is modified by + * reference. + */ + private static function unboundCoreConstraints(array &$constraints): void { + $names = preg_grep('/^drupal\/core-?/', array_keys($constraints)); + foreach ($names as $name) { + $constraints[$name] = '*'; + } + } + + /** + * Creates a Composer repository for all dependencies of the test project. + * + * We always reference third-party dependencies (i.e., any package that isn't + * part of Drupal core) from the main project which is running this test. + * + * Packages that are part of Drupal core -- such as `drupal/core`, + * `drupal/core-composer-scaffold`, and so on -- are expected to have been + * copied into the workspace directory, so that we can modify them as needed. + * + * The file will be written to WORKSPACE_DIR/vendor.json. + * + * @param string[] $versions + * (optional) The versions of specific packages, keyed by package name. + * Versions of packages not in this array will be determined first by + * looking for a `version` key in the package's composer.json, then by + * calling \Composer\InstalledVersions::getPrettyVersion(). If none of that + * works, `dev-main` will be used as the package's version. + */ + protected function createVendorRepository(array $versions = []): void { + $packages = []; + + $class_loaders = ClassLoader::getRegisteredLoaders(); + + $workspace_dir = $this->getWorkspaceDirectory(); + $finder = Finder::create() + ->in([ + $this->getWorkspaceDrupalRoot() . '/core', + "$workspace_dir/composer/Metapackage", + "$workspace_dir/composer/Plugin", + key($class_loaders), + ]) + ->depth('< 3') + ->files() + ->name('composer.json'); + + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($finder as $file) { + $package_info = json_decode($file->getContents(), TRUE, flags: JSON_THROW_ON_ERROR); + $name = $package_info['name']; + + $requirements = $package_info['require'] ?? []; + // These polyfills are dependencies of some packages, but for reasons we + // don't understand, they are not installed in code bases built on PHP + // versions that are newer than the ones being polyfilled, which means we + // won't be able to build our test project because these polyfills aren't + // available in the local code base. Since we're guaranteed to be on PHP + // 8.3 or later, no package should need to polyfill older versions. + unset( + $requirements['symfony/polyfill-php72'], + $requirements['symfony/polyfill-php73'], + $requirements['symfony/polyfill-php74'], + $requirements['symfony/polyfill-php80'], + $requirements['symfony/polyfill-php81'], + $requirements['symfony/polyfill-php82'], + $requirements['symfony/polyfill-php83'], + ); + // If this package requires any Drupal core packages, ensure it allows + // any version. + self::unboundCoreConstraints($requirements); + // In certain situations, like Drupal CI, auto_updates might be + // required into the code base by Composer. This may cause it to be added to + // the drupal/core-recommended metapackage, which can prevent the test site + // from being built correctly, among other deleterious effects. To prevent + // such shenanigans, always remove drupal/auto_updates from + // drupal/core-recommended. + if ($name === 'drupal/core-recommended') { + unset($requirements['drupal/auto_updates']); + } + + try { + $version = $versions[$name] ?? $package_info['version'] ?? InstalledVersions::getPrettyVersion($name); + } + catch (\OutOfBoundsException) { + $version = 'dev-main'; + } + + // Create a pared-down package definition that has just enough information + // for Composer to install the package from the local copy: the name, + // version, package type, source path ("dist" in Composer terminology), + // and the autoload information, so that the classes provided by the + // package will actually be loadable in the test site we're building. + $path = $file->getPath(); + $packages[$name][$version] = [ + 'name' => $name, + 'version' => $version, + 'type' => $package_info['type'] ?? 'library', + // Disabling symlinks in the transport options doesn't seem to have an + // effect, so we use the COMPOSER_MIRROR_PATH_REPOS environment + // variable to force mirroring in ::createTestProject(). + 'dist' => [ + 'type' => 'path', + 'url' => $path, + ], + 'require' => $requirements, + 'autoload' => $package_info['autoload'] ?? [], + 'provide' => $package_info['provide'] ?? [], + // Composer plugins are loaded and activated as early as possible, and + // they must have a `class` key defined in their `extra` section, along + // with a dependency on `composer-plugin-api` (plus any other real + // runtime dependencies). This is also necessary for packages that ship + // scaffold files, like Drupal core. + 'extra' => $package_info['extra'] ?? [], + ]; + } + $data = json_encode(['packages' => $packages], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + file_put_contents($workspace_dir . '/vendor.json', $data); + } + + /** + * Runs a Composer command and returns its output. + * + * Always asserts that the command was executed successfully. + * + * @param string $command + * The command to execute, including the `composer` invocation. + * @param string|null $working_dir + * (optional) A working directory relative to the workspace, within which to + * execute the command. Defaults to the workspace directory. + * @param bool $json + * (optional) Whether to parse the command's output as JSON before returning + * it. Defaults to FALSE. + * + * @return mixed|string|null + * The command's output, optionally parsed as JSON. + */ + protected function runComposer(string $command, ?string $working_dir = NULL, bool $json = FALSE) { + $process = $this->executeCommand($command, $working_dir); + $this->assertCommandSuccessful(); + + $output = trim($process->getOutput()); + if ($json) { + $output = json_decode($output, TRUE, flags: JSON_THROW_ON_ERROR); + } + return $output; + } + + /** + * Appends PHP code to the test site's settings.php. + * + * @param string $php + * The PHP code to append to the test site's settings.php. + */ + protected function writeSettings(string $php): void { + // Ensure settings are writable, since this is the only way we can set + // configuration values that aren't accessible in the UI. + $file = $this->getWebRoot() . '/sites/default/settings.php'; + $this->assertFileExists($file); + chmod(dirname($file), 0744); + chmod($file, 0744); + $this->assertFileIsWritable($file); + $this->assertIsInt(file_put_contents($file, $php, FILE_APPEND)); + } + + /** + * Installs modules in the UI. + * + * Assumes that a user with the appropriate permissions is logged in. + * + * @param string[] $modules + * The machine names of the modules to install. + */ + protected function installModules(array $modules): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $assert_session = $mink->assertSession(); + + $this->visit('/admin/modules'); + foreach ($modules as $module) { + $page->checkField("modules[$module][enable]"); + } + $page->pressButton('Install'); + + // If there is a confirmation form warning about additional dependencies + // or non-stable modules, submit it. + $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]') + ->getValue(); + if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) { + $page->pressButton('Continue'); + $assert_session->statusCodeEquals(200); + } + } + + /** + * Copies a fixture directory to a temporary directory and returns its path. + * + * @param string $fixture_directory + * The fixture directory. + * + * @return string + * The temporary directory. + */ + protected function copyFixtureToTempDirectory(string $fixture_directory): string { + $temp_directory = $this->getWorkspaceDirectory() . '/fixtures_temp_' . $this->randomMachineName(20); + static::copyFixtureFilesTo($fixture_directory, $temp_directory); + return $temp_directory; + } + + /** + * Asserts stage events were fired in a specific order. + * + * @param string $expected_stage_class + * The expected stage class for the events. + * @param array|null $expected_events + * (optional) The expected stage events that should have been fired in the + * order in which they should have been fired. Events can be specified more + * that once if they will be fired multiple times. If there are no events + * specified all life cycle events from PreCreateEvent to PostApplyEvent + * will be asserted. + * @param int $wait + * (optional) How many seconds to wait for the events to be fired. Defaults + * to 0. + * @param string $message + * (optional) A message to display with the assertion. + * + * @see \Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber::logEventInfo + */ + protected function assertExpectedStageEventsFired(string $expected_stage_class, ?array $expected_events = NULL, int $wait = 0, string $message = ''): void { + if ($expected_events === NULL) { + $expected_events = EventLogSubscriber::getSubscribedEvents(); + // The event subscriber uses this event to ensure the log file is excluded + // from Package Manager operations, but it's not relevant for our purposes + // because it's not part of the stage life cycle. + unset($expected_events[CollectPathsToExcludeEvent::class]); + $expected_events = array_keys($expected_events); + } + $this->assertNotEmpty($expected_events); + + $log_file = $this->getWorkspaceDirectory() . '/project/' . EventLogSubscriber::LOG_FILE_NAME; + $max_wait = time() + $wait; + do { + $this->assertFileIsReadable($log_file); + $log_data = file_get_contents($log_file); + $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR); + + // Filter out events logged by any other stage. + $log_data = array_filter($log_data, fn (array $event): bool => $event['stage'] === $expected_stage_class); + + // If we've logged at least the expected number of events, stop waiting. + // Break out of the loop and assert the expected events were logged. + if (count($log_data) >= count($expected_events)) { + break; + } + // Wait a bit before checking again. + sleep(5); + } while ($max_wait > time()); + + $this->assertSame($expected_events, array_column($log_data, 'event'), $message); + } + + /** + * Visits the 'admin/reports/dblog' and selects Package Manager's change log. + */ + private function visitPackageManagerChangeLog(): void { + $mink = $this->getMink(); + $assert_session = $mink->assertSession(); + $page = $mink->getSession()->getPage(); + + $this->visit('/admin/reports/dblog'); + $assert_session->statusCodeEquals(200); + $page->selectFieldOption('Type', 'package_manager_change_log'); + $page->pressButton('Filter'); + $assert_session->statusCodeEquals(200); + } + + /** + * Asserts changes requested during the stage life cycle were logged. + * + * This method specifically asserts changes that were *requested* (i.e., + * during the require phase) rather than changes that were actually applied. + * The requested and applied changes may be exactly the same, or they may + * differ (for example, if a secondary dependency was added or updated in the + * stage directory). + * + * @param string[] $expected_requested_changes + * The expected requested changes. + * + * @see ::assertAppliedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertRequestedChangesWereLogged(array $expected_requested_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Requested changes:")') + ->click(); + array_walk($expected_requested_changes, $assert_session->pageTextContains(...)); + } + + /** + * Asserts that changes applied during the stage life cycle were logged. + * + * This method specifically asserts changes that were *applied*, rather than + * the changes that were merely requested. For example, if a package was + * required into the stage and it added a secondary dependency, that change + * will be considered one of the applied changes, not a requested change. + * + * @param string[] $expected_applied_changes + * The expected applied changes. + * + * @see ::assertRequestedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertAppliedChangesWereLogged(array $expected_applied_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Applied changes:")') + ->click(); + array_walk($expected_applied_changes, $assert_session->pageTextContains(...)); + } + + /** + * Gets a /package-manager-test-api response. + * + * @param string $url + * The package manager test API URL to fetch. + * @param array $query_data + * The query data. + */ + protected function makePackageManagerTestApiRequest(string $url, array $query_data): void { + $url .= '?' . http_build_query($query_data); + $this->visit($url); + $session = $this->getMink()->getSession(); + + // Ensure test failures provide helpful debug output when there's a fatal + // PHP error: don't use \Behat\Mink\WebAssert::statusCodeEquals(). + $message = sprintf( + "Error response: %s\n\nHeaders: %s\n\nServer error log: %s", + $session->getPage()->getContent(), + var_export($session->getResponseHeaders(), TRUE), + $this->serverErrorLog, + ); + $this->assertSame(200, $session->getStatusCode(), $message); + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(?\Iterator $iterator = NULL, $working_dir = NULL): void { + parent::copyCodebase($iterator, $working_dir); + + // Create a local Composer repository for all third-party dependencies and + // core packages. + $this->createVendorRepository(); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php new file mode 100644 index 000000000000..f82e051eb821 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\package_manager\ComposerInspector; +use Drupal\Tests\BrowserTestBase; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * Tests that Package Manager shows the Composer version on the status report. + * + * @group package_manager + * @internal + */ +class ComposerRequirementTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that Composer version and path are listed on the status report. + */ + public function testComposerInfoShown(): void { + $config = $this->config('package_manager.settings'); + + // Ensure we can locate the Composer executable. + /** @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $executable_finder */ + $executable_finder = $this->container->get(ExecutableFinderInterface::class); + $composer_path = $executable_finder->find('composer'); + $composer_version = $this->container->get(ComposerInspector::class)->getVersion(); + + // With a valid path to Composer, ensure the status report shows its version + // number and path. + $config->set('executables.composer', $composer_path)->save(); + $account = $this->drupalCreateUser(['administer site configuration']); + $this->drupalLogin($account); + $this->drupalGet('/admin/reports/status'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Composer version'); + $assert_session->responseContains("$composer_version (<code>$composer_path</code>)"); + + // If the path to Composer is invalid, we should see the error message + // that gets raised when we try to get its version. + $config->set('executables.composer', '/path/to/composer')->save(); + $this->getSession()->reload(); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains('Composer was not found. The error message was: Failed to run process: The command "\'/path/to/composer\' \'--format=json\'" failed.'); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php new file mode 100644 index 000000000000..5a5509d31c86 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; + +/** + * Tests that Package Manager's requirements check for the failure marker. + * + * @group package_manager + * @internal + */ +class FailureMarkerRequirementTest extends BrowserTestBase { + use StringTranslationTrait; + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that error is shown if failure marker already exists. + */ + public function testFailureMarkerExists(): void { + $account = $this->drupalCreateUser([ + 'administer site configuration', + ]); + $this->drupalLogin($account); + + $fake_project_root = $this->root . DIRECTORY_SEPARATOR . $this->publicFilesDirectory; + $this->container->get(PathLocator::class) + ->setPaths($fake_project_root, NULL, NULL, NULL); + + $failure_marker = $this->container->get(FailureMarker::class); + $message = $this->t('Package Manager is here to wreck your day.'); + $stage = new class() extends StageBase { + + public function __construct() {} + + /** + * {@inheritdoc} + */ + protected string $type = 'test'; + }; + $failure_marker->write($stage, $message); + $path = $failure_marker->getPath(); + $this->assertFileExists($path); + $this->assertStringStartsWith($fake_project_root, $path); + + $this->drupalGet('/admin/reports/status'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Failed Package Manager update detected'); + $assert_session->pageTextContains($message->render()); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/GenericTest.php b/core/modules/package_manager/tests/src/Functional/GenericTest.php new file mode 100644 index 000000000000..d096f0c73788 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/GenericTest.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for package_manager. + * + * @group package_manager + */ +class GenericTest extends GenericModuleTestBase {} diff --git a/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php new file mode 100644 index 000000000000..96c80e75d723 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\AllowedScaffoldPackagesValidator + * @group package_manager + * @internal + */ +class AllowedScaffoldPackagesValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests that the allowed-packages setting is validated during pre-create. + */ + public function testPreCreate(): void { + (new ActiveFixtureManipulator())->addConfig([ + 'extra.drupal-scaffold.allowed-packages' => [ + "drupal/dummy_scaffolding", + "drupal/dummy_scaffolding_2", + ], + ])->commitChanges(); + + $result = ValidationResult::createError( + [ + t("drupal/dummy_scaffolding"), + t("drupal/dummy_scaffolding_2"), + ], + t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href="https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold">the scaffold documentation</a> for more information.') + ); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that the allowed-packages setting is validated during pre-apply. + */ + public function testPreApply(): void { + $this->getStageFixtureManipulator() + ->addConfig([ + 'extra.drupal-scaffold.allowed-packages' => [ + "drupal/dummy_scaffolding", + ], + ]); + + $result = ValidationResult::createError( + [ + t("drupal/dummy_scaffolding"), + ], + t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href="https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold">the scaffold documentation</a> for more information.') + ); + $this->assertResults([$result], PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php new file mode 100644 index 000000000000..56c8984388b3 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator; +use Drupal\package_manager\Validator\BaseRequirementValidatorTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @covers \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + * @covers \Drupal\package_manager\Validator\BaseRequirementValidatorTrait + * + * @group package_manager + */ +class BaseRequirementsFulfilledValidatorTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + + /** + * The event class to throw to an error for. + * + * @var string + */ + private string $eventClass; + + /** + * {@inheritdoc} + */ + public function validate(PreOperationStageEvent $event): void { + if (get_class($event) === $this->eventClass) { + $event->addError([ + t('This will not stand!'), + ]); + } + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->container->get('event_dispatcher')->addSubscriber($this); + } + + /** + * Data provider for ::testBaseRequirement(). + * + * @return array[] + * The test cases. + */ + public static function providerBaseRequirement(): array { + return [ + [PreCreateEvent::class], + [PreRequireEvent::class], + [PreApplyEvent::class], + [StatusCheckEvent::class], + ]; + } + + /** + * Tests that base requirement failures stop event propagation. + * + * @param string $event_class + * The event which should raise a base requirement error, and thus stop + * event propagation. + * + * @dataProvider providerBaseRequirement + */ + public function testBaseRequirement(string $event_class): void { + $this->eventClass = $event_class; + + $validator = $this->container->get(BaseRequirementsFulfilledValidator::class); + $this->assertEventPropagationStopped($event_class, [$validator, 'validate']); + + $result = ValidationResult::createError([ + t('This will not stand!'), + ]); + + if ($event_class === StatusCheckEvent::class) { + $this->assertStatusCheckResults([$result]); + } + else { + $this->assertResults([$result], $event_class); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php b/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php new file mode 100644 index 000000000000..8a7d5e411698 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Psr\Log\LogLevel; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ChangeLogger + * @group package_manager + */ +class ChangeLoggerTest extends PackageManagerKernelTestBase { + + /** + * The logger to which change lists will be written. + * + * @var \ColinODell\PsrTestLogger\TestLogger + */ + private TestLogger $logger; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + $this->logger = new TestLogger(); + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + $container->set('logger.channel.package_manager_change_log', $this->logger); + } + + /** + * Tests that the requested and applied changes are logged. + */ + public function testChangeLogging(): void { + $this->setReleaseMetadata([ + 'semver_test' => __DIR__ . '/../../fixtures/release-history/semver_test.1.1.xml', + ]); + + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'package/removed', + 'type' => 'library', + ]) + ->commitChanges(); + + $this->getStageFixtureManipulator() + ->setCorePackageVersion('9.8.1') + ->addPackage([ + 'name' => 'drupal/semver_test', + 'type' => 'drupal-module', + 'version' => '8.1.1', + ]) + ->removePackage('package/removed'); + + $stage = $this->createStage(); + $stage->create(); + $stage->require([ + 'drupal/semver_test:*', + 'drupal/core-recommended:^9.8.1', + ]); + // Nothing should be logged until post-apply. + $stage->apply(); + $this->assertEmpty($this->logger->records); + $stage->postApply(); + + $this->assertTrue($this->logger->hasInfoRecords()); + $records = $this->logger->recordsByLevel[LogLevel::INFO]; + $this->assertCount(2, $records); + // The first record should be of the requested changes. + $expected_message = <<<END +Requested changes: +- Update drupal/core-recommended from 9.8.0 to ^9.8.1 +- Install drupal/semver_test * (any version) +END; + $this->assertSame($expected_message, (string) $records[0]['message']); + + // The second record should be of the actual changes. + $expected_message = <<<END +Applied changes: +- Updated drupal/core from 9.8.0 to 9.8.1 +- Updated drupal/core-dev from 9.8.0 to 9.8.1 +- Updated drupal/core-recommended from 9.8.0 to 9.8.1 +- Installed drupal/semver_test 8.1.1 +- Uninstalled package/removed +END; + $this->assertSame($expected_message, (string) $records[1]['message']); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php new file mode 100644 index 000000000000..aa3e4943278f --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -0,0 +1,560 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Composer\Json\JsonFile; +use Drupal\Component\Serialization\Json; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Exception\ComposerNotReadyException; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Exception\RuntimeException; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; +use Prophecy\Argument; +use Prophecy\Prophecy\ObjectProphecy; + +/** + * @coversDefaultClass \Drupal\package_manager\ComposerInspector + * + * @group package_manager + */ +class ComposerInspectorTest extends PackageManagerKernelTestBase { + + use InstalledPackagesListTrait; + + /** + * @covers ::getConfig + */ + public function testConfig(): void { + $dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $inspector = $this->container->get(ComposerInspector::class); + $this->assertTrue((bool) Json::decode($inspector->getConfig('secure-http', $dir))); + + $this->assertSame([ + 'boo' => 'boo boo', + "foo" => ["dev" => "2.x-dev"], + "foo-bar" => TRUE, + "boo-far" => [ + "foo" => 1.23, + "bar" => 134, + "foo-bar" => NULL, + ], + 'baz' => NULL, + 'installer-paths' => [ + 'modules/contrib/{$name}' => ['type:drupal-module'], + 'profiles/contrib/{$name}' => ['type:drupal-profile'], + 'themes/contrib/{$name}' => ['type:drupal-theme'], + ], + ], Json::decode($inspector->getConfig('extra', $dir))); + + try { + $inspector->getConfig('non-existent-config', $dir); + $this->fail('Expected an exception when trying to get a non-existent config key, but none was thrown.'); + } + catch (RuntimeException) { + // We don't need to do anything here. + } + + // If composer.json is removed, we should get an exception because + // getConfig() should validate that $dir is Composer-ready. + unlink($dir . '/composer.json'); + $this->expectExceptionMessage("composer.json not found."); + $inspector->getConfig('extra', $dir); + } + + /** + * @covers ::getConfig + */ + public function testConfigUndefinedKey(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $inspector = $this->container->get(ComposerInspector::class); + + // Overwrite the composer.json file and treat it as a + $file = new JsonFile($project_root . '/composer.json'); + $this->assertTrue($file->exists()); + $data = $file->read(); + // Ensure that none of the special keys are defined, to test the fallback + // behavior. + unset( + $data['minimum-stability'], + $data['extra'] + ); + $file->write($data); + + $path = $file->getPath(); + $this->assertSame('stable', $inspector->getConfig('minimum-stability', $path)); + $this->assertSame([], Json::decode($inspector->getConfig('extra', $path))); + } + + /** + * @covers ::getInstalledPackagesList + */ + public function testGetInstalledPackagesList(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $list = $inspector->getInstalledPackagesList($project_root); + + $expected_list = new InstalledPackagesList([ + 'drupal/core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core', + 'type' => 'drupal-core', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core", + ]), + 'drupal/core-recommended' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-recommended', + 'type' => 'project', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core-recommended", + ]), + 'drupal/core-dev' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev', + 'type' => 'package', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core-dev", + ]), + ]); + + $this->assertPackageListsEqual($expected_list, $list); + + // Since the lock file hasn't changed, we should get the same package list + // back if we call getInstalledPackageList() again. + $this->assertSame($list, $inspector->getInstalledPackagesList($project_root)); + + // If we change the lock file, we should get a different package list. + $lock_file = new JsonFile($project_root . '/composer.lock'); + $lock_data = $lock_file->read(); + $this->assertArrayHasKey('_readme', $lock_data); + unset($lock_data['_readme']); + $lock_file->write($lock_data); + $this->assertNotSame($list, $inspector->getInstalledPackagesList($project_root)); + + // If composer.lock is removed, we should get an exception because + // getInstalledPackagesList() should validate that $project_root is + // Composer-ready. + unlink($lock_file->getPath()); + $this->expectExceptionMessage("composer.lock not found in $project_root."); + $inspector->getInstalledPackagesList($project_root); + } + + /** + * @covers ::validate + */ + public function testComposerUnavailable(): void { + $precondition = $this->prophesize(ComposerIsAvailableInterface::class); + $mocked_precondition = $precondition->reveal(); + $this->container->set(ComposerIsAvailableInterface::class, $mocked_precondition); + + $message = $this->createComposeStagerMessage("Well, that didn't work."); + $precondition->assertIsFulfilled(Argument::cetera()) + ->willThrow(new PreconditionException($mocked_precondition, $message)) + // The result of the precondition is statically cached, so it should only + // be called once even though we call validate() twice. + ->shouldBeCalledOnce(); + + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (ComposerNotReadyException $e) { + $this->assertNull($e->workingDir); + $this->assertSame("Well, that didn't work.", $e->getMessage()); + } + + // Call validate() again to ensure the precondition is called once. + $this->expectException(ComposerNotReadyException::class); + $this->expectExceptionMessage("Well, that didn't work."); + $inspector->validate($project_root); + } + + /** + * Tests what happens when composer.json or composer.lock are missing. + * + * @param string $filename + * The filename to delete, which should cause validate() to raise an + * error. + * + * @covers ::validate + * + * @testWith ["composer.json"] + * ["composer.lock"] + */ + public function testComposerFilesDoNotExist(string $filename): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $file_path = $project_root . '/' . $filename; + unlink($file_path); + + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + } + catch (ComposerNotReadyException $e) { + $this->assertSame($project_root, $e->workingDir); + $this->assertStringContainsString("$filename not found", $e->getMessage()); + } + } + + /** + * @param string|null $reported_version + * The version of Composer that will be returned by ::getVersion(). + * @param string|null $expected_message + * The error message that should be generated for the reported version of + * Composer. If not passed, will default to the message format defined in + * ::validate(). + * + * @covers ::validate + * + * @testWith ["2.2.12", "<default>"] + * ["2.2.13", "<default>"] + * ["2.5.0", "<default>"] + * ["2.5.5", "<default>"] + * ["2.5.11", "<default>"] + * ["2.6.0", null] + * ["2.2.11", "<default>"] + * ["2.2.0-dev", "<default>"] + * ["2.3.6", "<default>"] + * ["2.4.1", "<default>"] + * ["2.3.4", "<default>"] + * ["2.1.6", "<default>"] + * ["1.10.22", "<default>"] + * ["1.7.3", "<default>"] + * ["2.0.0-alpha3", "<default>"] + * ["2.1.0-RC1", "<default>"] + * ["1.0.0-RC", "<default>"] + * ["1.0.0-beta1", "<default>"] + * ["1.9-dev", "<default>"] + * ["@package_version@", "Invalid version string \"@package_version@\""] + * [null, "Unable to determine Composer version"] + */ + public function testVersionCheck(?string $reported_version, ?string $expected_message): void { + $runner = $this->mockComposerRunner($reported_version); + + // Mock the ComposerIsAvailableInterface so that if it uses the Composer + // runner it will not affect the test expectations. + $composerPrecondition = $this->prophesize(ComposerIsAvailableInterface::class); + $composerPrecondition + ->assertIsFulfilled(Argument::cetera()) + ->shouldBeCalledOnce(); + $this->container->set(ComposerIsAvailableInterface::class, $composerPrecondition->reveal()); + + // The result of the version check is statically cached, so the runner + // should only be called once, even though we call validate() twice in this + // test. + $runner->getMethodProphecies('run')[0]->withArguments([['--format=json'], NULL, [], Argument::any()])->shouldBeCalledOnce(); + // The runner should be called with `validate` as the first argument, but + // it won't affect the outcome of this test. + $runner->run(Argument::withEntry(0, 'validate')); + $this->container->set(ComposerProcessRunnerInterface::class, $runner->reveal()); + + if ($expected_message === '<default>') { + $expected_message = "The detected Composer version, $reported_version, does not satisfy <code>" . ComposerInspector::SUPPORTED_VERSION . '</code>.'; + } + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + // If we expected the version check to succeed, ensure we did not expect + // an exception message. + $this->assertNull($expected_message, 'Expected an exception, but none was thrown.'); + } + catch (ComposerNotReadyException $e) { + $this->assertNull($e->workingDir); + $this->assertSame($expected_message, $e->getMessage()); + } + + if (isset($expected_message)) { + $this->expectException(ComposerNotReadyException::class); + $this->expectExceptionMessage($expected_message); + } + $inspector->validate($project_root); + } + + /** + * @covers ::getVersion + * + * @testWith ["2.5.6"] + * [null] + */ + public function testGetVersion(?string $reported_version): void { + $this->container->set(ComposerProcessRunnerInterface::class, $this->mockComposerRunner($reported_version)->reveal()); + + if (empty($reported_version)) { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to determine Composer version'); + } + $this->assertSame($reported_version, $this->container->get(ComposerInspector::class)->getVersion()); + } + + /** + * @covers ::validate + */ + public function testComposerValidateIsCalled(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + // Put an invalid value into composer.json and ensure it gets surfaced as + // an exception. + $file = new JsonFile($project_root . '/composer.json'); + $this->assertTrue($file->exists()); + $data = $file->read(); + $data['prefer-stable'] = 'truthy'; + $file->write($data); + + try { + $this->container->get(ComposerInspector::class) + ->validate($project_root); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (ComposerNotReadyException $e) { + $this->assertSame($project_root, $e->workingDir); + // The exception message is translated by Composer Stager and HTML-escaped + // by Drupal's markup system, which is why there's a " in the + // final exception message. + $this->assertStringContainsString('composer.json" does not match the expected JSON schema', $e->getMessage()); + $this->assertStringContainsString('prefer-stable : String value found, but a boolean is required', $e->getPrevious()?->getMessage()); + } + } + + /** + * @covers ::getRootPackageInfo + */ + public function testRootPackageInfo(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $info = $this->container->get(ComposerInspector::class) + ->getRootPackageInfo($project_root); + $this->assertSame('fake/site', $info['name']); + } + + /** + * Tests that the installed path of metapackages is always NULL. + * + * @param bool $is_metapackage + * Whether the test package will be a metapackage. + * @param string|null $install_path + * The package install path that Composer should report. If NULL, the + * reported path will be unchanged. The token <PROJECT_ROOT> will be + * replaced with the project root. + * @param string|null $exception_message + * The expected exception message, or NULL if no exception should be thrown. + * The token <PROJECT_ROOT> will be replaced with the project root. + * + * @covers ::getInstalledPackagesList + * + * @testWith [true, null, null] + * [true, "<PROJECT_ROOT>/another/directory", "Metapackage 'test/package' is installed at unexpected path: '<PROJECT_ROOT>/another/directory', expected NULL"] + * [false, null, null] + * [false, "<PROJECT_ROOT>", "Package 'test/package' cannot be installed at path: '<PROJECT_ROOT>'"] + */ + public function testMetapackagePath(bool $is_metapackage, ?string $install_path, ?string $exception_message): void { + $inspector = new class ( + $this->container->get(ComposerProcessRunnerInterface::class), + $this->container->get(ComposerIsAvailableInterface::class), + $this->container->get(PathFactoryInterface::class), + ) extends ComposerInspector { + + /** + * The install path that Composer should report for `test/package`. + * + * If not set, the reported install path will not be changed. + * + * @var string + */ + public $packagePath; + + /** + * {@inheritdoc} + */ + protected function show(string $working_dir): array { + $data = parent::show($working_dir); + if ($this->packagePath) { + $data['test/package']['path'] = $this->packagePath; + } + return $data; + } + + }; + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + if ($install_path) { + $install_path = str_replace('<PROJECT_ROOT>', $project_root, $install_path); + + // The install path must actually exist. + if (!is_dir($install_path)) { + $this->assertTrue(mkdir($install_path, 0777, TRUE)); + } + $inspector->packagePath = $install_path; + } + + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'test/package', + 'type' => $is_metapackage ? 'metapackage' : 'library', + ]) + ->commitChanges(); + + if ($exception_message) { + $this->expectException(\UnexpectedValueException::class); + $exception_message = str_replace('<PROJECT_ROOT>', $project_root, $exception_message); + $this->expectExceptionMessage($exception_message); + } + $list = $inspector->getInstalledPackagesList($project_root); + $this->assertArrayHasKey('test/package', $list); + // If the package is a metapackage, its path should be NULL. + $this->assertSame($is_metapackage, is_null($list['test/package']->path)); + } + + /** + * Tests that the commit hash of a dev snapshot package is ignored. + */ + public function testPackageDevSnapshotCommitHashIsRemoved(): void { + $inspector = new class ( + $this->container->get(ComposerProcessRunnerInterface::class), + $this->container->get(ComposerIsAvailableInterface::class), + $this->container->get(PathFactoryInterface::class), + ) extends ComposerInspector { + + /** + * {@inheritdoc} + */ + protected function show(string $working_dir): array { + return [ + 'test/package' => [ + 'name' => 'test/package', + 'path' => __DIR__, + 'version' => '1.0.x-dev 0a1b2c3d', + ], + ]; + } + + }; + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $list = $inspector->getInstalledPackagesList($project_root); + $this->assertSame('1.0.x-dev', $list['test/package']->version); + } + + /** + * Data provider for ::testAllowedPlugins(). + * + * @return array[] + * The test cases. + */ + public static function providerAllowedPlugins(): array { + return [ + 'all plugins allowed' => [ + ['allow-plugins' => TRUE], + TRUE, + ], + 'no plugins allowed' => [ + ['allow-plugins' => FALSE], + [], + ], + 'some plugins allowed' => [ + [ + 'allow-plugins.example/plugin-a' => TRUE, + 'allow-plugins.example/plugin-b' => FALSE, + ], + [ + 'example/plugin-a' => TRUE, + 'example/plugin-b' => FALSE, + // The scaffold plugin is explicitly disallowed by the fake_site + // fixture. + 'drupal/core-composer-scaffold' => FALSE, + ], + ], + ]; + } + + /** + * Tests ComposerInspector's parsing of the allowed plugins list. + * + * @param array $config + * The Composer configuration to set. + * @param array|bool $expected_value + * The expected return value from getAllowPluginsConfig(). + * + * @covers ::getAllowPluginsConfig + * + * @dataProvider providerAllowedPlugins + */ + public function testAllowedPlugins(array $config, bool|array $expected_value): void { + (new ActiveFixtureManipulator()) + ->addConfig($config) + ->commitChanges(); + + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $actual_value = $this->container->get(ComposerInspector::class) + ->getAllowPluginsConfig($project_root); + + if (is_array($expected_value)) { + ksort($expected_value); + } + if (is_array($actual_value)) { + ksort($actual_value); + } + $this->assertSame($expected_value, $actual_value); + } + + /** + * Mocks the Composer runner service to return a particular version string. + * + * @param string|null $reported_version + * The version number that `composer --format=json` should return. + * + * @return \Prophecy\Prophecy\ObjectProphecy + * The configurator for the mocked Composer runner. + */ + private function mockComposerRunner(?string $reported_version): ObjectProphecy { + $runner = $this->prophesize(ComposerProcessRunnerInterface::class); + + $pass_version_to_output_callback = function (array $arguments_passed_to_runner) use ($reported_version): void { + $command_output = Json::encode([ + 'application' => [ + 'name' => 'Composer', + 'version' => $reported_version, + ], + ]); + + $callback = end($arguments_passed_to_runner); + assert($callback instanceof OutputCallbackInterface); + $callback(OutputTypeEnum::OUT, $command_output); + }; + + // We expect the runner to be called with two arguments: an array whose + // first item is `--format=json`, and an output callback. + $runner->run( + Argument::withEntry(0, '--format=json'), + Argument::cetera(), + )->will($pass_version_to_output_callback); + + return $runner; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php new file mode 100644 index 000000000000..ce6c4a60a4b8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerMinimumStabilityValidator + * @group package_manager + * @internal + */ +class ComposerMinimumStabilityValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests error if requested version is less stable than the minimum: stable. + */ + public function testPreRequireEvent(): void { + $stage = $this->createStage(); + $stage->create(); + $result = ValidationResult::createError([ + t("<code>drupal/core</code>'s requested version 9.8.1-beta1 is less stable (beta) than the minimum stability (stable) required in <PROJECT_ROOT>/composer.json."), + ]); + try { + $stage->require(['drupal/core:9.8.1-beta1']); + $this->fail('Able to require a package even though it did not meet minimum stability.'); + } + catch (StageEventException $exception) { + $this->assertValidationResultsEqual([$result], $exception->event->getResults()); + } + $stage->destroy(); + + // Specifying a stability flag bypasses this check. + $stage->create(); + $stage->require(['drupal/core:9.8.1-beta1@dev']); + $stage->destroy(); + + // Dev packages are also checked. + $stage->create(); + $result = ValidationResult::createError([ + t("<code>drupal/core-dev</code>'s requested version 9.8.x-dev is less stable (dev) than the minimum stability (stable) required in <PROJECT_ROOT>/composer.json."), + ]); + try { + $stage->require([], ['drupal/core-dev:9.8.x-dev']); + $this->fail('Able to require a package even though it did not meet minimum stability.'); + } + catch (StageEventException $exception) { + $this->assertValidationResultsEqual([$result], $exception->event->getResults()); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php new file mode 100644 index 000000000000..d3652407d8db --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php @@ -0,0 +1,292 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Url; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerPatchesValidator + * @group package_manager + * @internal + */ +class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase { + + const ABSENT = 0; + const CONFIG_ALLOWED_PLUGIN = 1; + const EXTRA_EXIT_ON_PATCH_FAILURE = 2; + const REQUIRE_PACKAGE_FROM_ROOT = 4; + const REQUIRE_PACKAGE_INDIRECTLY = 8; + + /** + * Data provider for testErrorDuringPreCreate(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerErrorDuringPreCreate(): array { + $summary = t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.'); + return [ + 'INVALID: exit-on-patch-failure missing' => [ + static::CONFIG_ALLOWED_PLUGIN | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'), + ], $summary), + ], + ], + 'INVALID: indirect dependency' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_INDIRECTLY, + [ + ValidationResult::createError([ + t('It must be a root dependency.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-not-a-root-dependency', + NULL, + ], + ], + 'VALID: present' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [], + ], + 'VALID: absent' => [ + static::ABSENT, + [], + ], + ]; + } + + /** + * Tests that the patcher configuration is validated during pre-create. + * + * @param int $options + * What aspects of the patcher are installed how. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerErrorDuringPreCreate + */ + public function testErrorDuringPreCreate(int $options, array $expected_results): void { + $active_manipulator = new ActiveFixtureManipulator(); + if ($options & static::CONFIG_ALLOWED_PLUGIN) { + $active_manipulator->addConfig(['allow-plugins.cweagans/composer-patches' => TRUE]); + } + if ($options & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $active_manipulator->addConfig(['extra.composer-exit-on-patch-failure' => TRUE]); + } + if ($options & static::REQUIRE_PACKAGE_FROM_ROOT) { + $active_manipulator->requirePackage('cweagans/composer-patches', '@dev'); + } + elseif ($options & static::REQUIRE_PACKAGE_INDIRECTLY) { + $active_manipulator->addPackage([ + 'type' => 'package', + 'name' => 'dummy/depends-on-composer-patches', + 'description' => 'A dummy package depending on cweagans/composer-patches', + 'version' => '1.0.0', + 'require' => ['cweagans/composer-patches' => '*'], + ]); + } + if ($options !== static::ABSENT) { + $active_manipulator->commitChanges(); + } + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Data provider for testErrorDuringPreApply() and testHelpLink(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerErrorDuringPreApply(): array { + $summary = t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.'); + + return [ + 'composer-patches present in stage, but not present in active' => [ + static::ABSENT, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + ], + ], + 'composer-patches partially present (exit missing) in stage, but not present in active' => [ + static::ABSENT, + static::CONFIG_ALLOWED_PLUGIN | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + NULL, + ], + ], + 'composer-patches present due to non-root dependency in stage, but not present in active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_INDIRECTLY, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + t('It must be a root dependency.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + 'package-manager-faq-composer-patches-not-a-root-dependency', + NULL, + ], + ], + 'composer-patches removed in stage, but present in active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + static::ABSENT, + [ + ValidationResult::createError([ + t('It cannot be removed by Package Manager.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + ], + ], + 'composer-patches present in stage and active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [], + [], + ], + 'composer-patches not present in stage and active' => [ + static::ABSENT, + static::ABSENT, + [], + [], + ], + ]; + } + + /** + * Tests the patcher's presence and configuration are validated on pre-apply. + * + * @param int $in_active + * Whether patcher is installed in active. + * @param int $in_stage + * Whether patcher is installed in stage. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerErrorDuringPreApply + */ + public function testErrorDuringPreApply(int $in_active, int $in_stage, array $expected_results): void { + // Simulate in active. + $active_manipulator = new ActiveFixtureManipulator(); + if ($in_active & static::CONFIG_ALLOWED_PLUGIN) { + $active_manipulator->addConfig(['allow-plugins.cweagans/composer-patches' => TRUE]); + } + if ($in_active & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $active_manipulator->addConfig(['extra.composer-exit-on-patch-failure' => TRUE]); + } + if ($in_active & static::REQUIRE_PACKAGE_FROM_ROOT) { + $active_manipulator->requirePackage('cweagans/composer-patches', '@dev'); + } + if ($in_active !== static::ABSENT) { + $active_manipulator->commitChanges(); + } + + // Simulate in stage. + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($in_stage & static::CONFIG_ALLOWED_PLUGIN) { + $stage_manipulator->addConfig([ + 'allow-plugins.cweagans/composer-patches' => TRUE, + ]); + } + if ($in_stage & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $stage_manipulator->addConfig([ + 'extra.composer-exit-on-patch-failure' => TRUE, + ]); + } + if ($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT && !($in_active & static::REQUIRE_PACKAGE_FROM_ROOT)) { + $stage_manipulator->requirePackage('cweagans/composer-patches', '1.7.333'); + } + if (!($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT) && $in_active & static::REQUIRE_PACKAGE_FROM_ROOT) { + $stage_manipulator + ->removePackage('cweagans/composer-patches'); + } + if ($in_stage & static::REQUIRE_PACKAGE_INDIRECTLY) { + $stage_manipulator->addPackage([ + 'type' => 'package', + 'name' => 'dummy/depends-on-composer-patches', + 'description' => 'A dummy package depending on cweagans/composer-patches', + 'version' => '1.0.0', + 'require' => ['cweagans/composer-patches' => '*'], + ]); + } + + $stage = $this->createStage(); + $stage->create(); + $stage_dir = $stage->getStageDirectory(); + $stage->require(['drupal/core:9.8.1']); + + try { + $stage->apply(); + // If we didn't get an exception, ensure we didn't expect any errors. + $this->assertSame([], $expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->event->getResults(), NULL, $stage_dir); + } + } + + /** + * Tests that validation errors can carry links to help. + * + * @param int $in_active + * Whether patcher is installed in active. + * @param int $in_stage + * Whether patcher is installed in stage. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param string[] $help_page_sections + * An associative array of fragments (anchors) in the online help. The keys + * should be the numeric indices of the validation result messages which + * should link to those fragments. + * + * @dataProvider providerErrorDuringPreApply + */ + public function testErrorDuringPreApplyWithHelp(int $in_active, int $in_stage, array $expected_results, array $help_page_sections): void { + $this->enableModules(['help']); + + foreach ($expected_results as $result_index => $result) { + $messages = $result->messages; + + foreach ($messages as $message_index => $message) { + if ($help_page_sections[$message_index]) { + // Get the link to the online documentation for the error message. + $url = Url::fromRoute('help.page', ['name' => 'package_manager']) + ->setOption('fragment', $help_page_sections[$message_index]) + ->toString(); + // Reformat the provided results so that they all have the link to the + // online documentation appended to them. + $messages[$message_index] = t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', ['@message' => $message, ':url' => $url]); + } + } + $expected_results[$result_index] = ValidationResult::createError($messages, $result->summary); + } + $this->testErrorDuringPreApply($in_active, $in_stage, $expected_results); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php new file mode 100644 index 000000000000..cf3c0638ed73 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php @@ -0,0 +1,405 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerPluginsValidator + * @group package_manager + * @internal + */ +class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests `config.allow-plugins: true` fails validation during pre-create. + */ + public function testInsecureConfigurationFailsValidationPreCreate(): void { + $active_manipulator = new ActiveFixtureManipulator(); + $active_manipulator->addConfig(['allow-plugins' => TRUE]); + $active_manipulator->commitChanges(); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests `config.allow-plugins: true` fails validation during pre-apply. + */ + public function testInsecureConfigurationFailsValidationPreApply(): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->addConfig(['allow-plugins' => TRUE]); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests composer plugins are validated during pre-create. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $active_manipulator = new ActiveFixtureManipulator(); + if ($composer_config_to_add) { + $active_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $active_manipulator->addPackage($package); + } + $active_manipulator->commitChanges(); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests composer plugins are validated during pre-apply. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($composer_config_to_add) { + $stage_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $stage_manipulator->addPackage($package); + } + + // Ensure \Drupal\package_manager\Validator\SupportedReleaseValidator does + // not complain. + $release_fixture_folder = __DIR__ . '/../../fixtures/release-history'; + $this->setReleaseMetadata([ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ]); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests adding a plugin that's not allowed by the allow-plugins config. + * + * The exception that this test looks for is not necessarily triggered by + * ComposerPluginsValidator; Composer will exit with an error if there is an + * installed plugin that is not allowed by the `allow-plugins` config. In + * practice, this means that whichever validator is the first one to do a + * Composer operation (via ComposerInspector) will get the exception -- it + * may or may not be ComposerPluginsValidator. + * + * This test is here to ensure that Composer's behavior remains consistent, + * even if we're not explicitly testing ComposerPluginsValidator here. + */ + public function testAddDisallowedPlugin(): void { + $this->getStageFixtureManipulator() + ->addPackage([ + 'name' => 'composer/plugin-c', + 'version' => '16.4', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ]); + + $expected_message = "composer/plugin-c contains a Composer plugin which is blocked by your allow-plugins config."; + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + try { + // We are trying to add package plugin-c but not allowing it in config, + // so we expect the operation to fail on PreApplyEvent. + $stage->apply(); + } + catch (StageEventException $e) { + // Processing is required because the error message we get from Composer + // contains multiple white spaces at the start or end of line. + $this->assertStringContainsString($expected_message, preg_replace('/\s\s+/', '', $e->getMessage())); + $this->assertInstanceOf(PreApplyEvent::class, $e->event); + } + } + + /** + * Tests additional composer plugins can be trusted during pre-create. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->summary || !str_contains(strtolower($v->summary->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreCreate($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + /** + * Tests additional composer plugins can be trusted during pre-apply. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->summary || !str_contains(strtolower($v->summary->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreApply($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + /** + * Generates simple test cases. + * + * @return \Generator + */ + public static function providerSimpleValidCases(): \Generator { + yield 'no composer plugins' => [ + [], + [ + [ + 'name' => "drupal/semver_test", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ], + ], + [], + ]; + + yield 'another supported composer plugin' => [ + [ + 'allow-plugins.drupal/core-vendor-hardening' => TRUE, + ], + [ + [ + 'name' => 'drupal/core-vendor-hardening', + 'version' => '9.8.0', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'a supported composer plugin for which any version is supported: party like it is Drupal 99!' => [ + [ + 'allow-plugins.drupal/core-composer-scaffold' => TRUE, + ], + [ + [ + 'name' => 'drupal/core-composer-scaffold', + 'version' => '99.0.0', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — pretty package name' => [ + [ + 'allow-plugins.composer/plugin-a' => FALSE, + ], + [ + [ + 'name' => 'composer/plugin-a', + 'version' => '6.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — normalized package name' => [ + [ + 'allow-plugins.composer/plugin-b' => FALSE, + ], + [ + [ + 'name' => 'composer/plugin-b', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + } + + /** + * Generates simple invalid test cases. + * + * @return \Generator + */ + public static function providerSimpleInvalidCases(): \Generator { + yield 'one UNsupported composer plugin — pretty package name' => [ + [ + 'allow-plugins.not-cweagans/not-composer-patches' => TRUE, + ], + [ + [ + 'name' => 'not-cweagans/not-composer-patches', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + 'version' => '6.1', + 'type' => 'composer-plugin', + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one UNsupported composer plugin — normalized package name' => [ + [ + 'allow-plugins.also-not-cweagans/also-not-composer-patches' => TRUE, + ], + [ + [ + 'name' => 'also-not-cweagans/also-not-composer-patches', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one supported composer plugin but incompatible version — newer version' => [ + [ + 'allow-plugins.phpstan/extension-installer' => TRUE, + ], + [ + [ + 'name' => 'phpstan/extension-installer', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one supported composer plugin but incompatible version — older version' => [ + [ + 'allow-plugins.dealerdirect/phpcodesniffer-composer-installer' => TRUE, + ], + [ + [ + 'name' => 'dealerdirect/phpcodesniffer-composer-installer', + 'version' => '0.6.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + } + + /** + * Generates complex invalid test cases based on the simple test cases. + * + * @return \Generator + */ + public static function providerComplexInvalidCases(): \Generator { + $valid_cases = iterator_to_array(static::providerSimpleValidCases()); + $invalid_cases = iterator_to_array(static::providerSimpleInvalidCases()); + $all_config = NestedArray::mergeDeepArray( + // First key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[0], $valid_cases + $invalid_cases) + ); + $all_packages = NestedArray::mergeDeepArray( + // Second key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[1], $valid_cases + $invalid_cases) + ); + + yield 'complex combination' => [ + $all_config, + $all_packages, + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'), + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'), + new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'), + ], + new TranslatableMarkup('Unsupported Composer plugins were detected.'), + ), + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php new file mode 100644 index 000000000000..7320d7a385b0 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php @@ -0,0 +1,173 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerValidator + * @group package_manager + * @internal + */ +class ComposerValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testComposerSettingsValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerComposerSettingsValidation(): array { + $summary = t("Composer settings don't satisfy Package Manager's requirements."); + + $secure_http_error = ValidationResult::createError([ + t('HTTPS must be enabled for Composer downloads. See <a href="https://getcomposer.org/doc/06-config.md#secure-http">the Composer documentation</a> for more information.'), + ], $summary); + $tls_error = ValidationResult::createError([ + t('TLS must be enabled for HTTPS Composer downloads. See <a href="https://getcomposer.org/doc/06-config.md#disable-tls">the Composer documentation</a> for more information.'), + t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.'), + ], $summary); + + return [ + 'secure-http set to FALSE' => [ + [ + 'secure-http' => FALSE, + ], + [$secure_http_error], + ], + 'secure-http explicitly set to TRUE' => [ + [ + 'secure-http' => TRUE, + ], + [], + ], + 'secure-http implicitly set to TRUE' => [ + [ + 'extra.unrelated' => TRUE, + ], + [], + ], + 'disable-tls set to TRUE' => [ + [ + 'disable-tls' => TRUE, + ], + [$tls_error], + ], + 'disable-tls implicitly set to FALSE' => [ + [ + 'extra.unrelated' => TRUE, + ], + [], + ], + 'explicitly set disable-tls to FALSE' => [ + [ + 'disable-tls' => FALSE, + ], + [], + ], + 'disable-tls set to TRUE + secure-http set to TRUE, message only for TLS, secure-http overridden' => [ + [ + 'disable-tls' => TRUE, + 'secure-http' => TRUE, + ], + [$tls_error], + ], + 'disable-tls set to TRUE + secure-http set to FALSE, message only for TLS' => [ + [ + 'disable-tls' => TRUE, + 'secure-http' => FALSE, + ], + [$tls_error], + ], + ]; + } + + /** + * Tests that Composer's settings are validated. + * + * @param array $config + * The config to set. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results, if any. + * + * @dataProvider providerComposerSettingsValidation + */ + public function testComposerSettingsValidation(array $config, array $expected_results): void { + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests that Composer's settings are validated during pre-apply. + * + * @param array $config + * The config to set. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results, if any. + * + * @dataProvider providerComposerSettingsValidation + */ + public function testComposerSettingsValidationDuringPreApply(array $config, array $expected_results): void { + $this->getStageFixtureManipulator()->addConfig($config); + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Data provider for ::testLinkToOnlineHelp(). + * + * @return array[] + * The test cases. + */ + public static function providerLinkToOnlineHelp(): array { + return [ + 'TLS disabled' => [ + ['disable-tls' => TRUE], + [ + t('TLS must be enabled for HTTPS Composer downloads. See <a href="/admin/help/package_manager#package-manager-requirements">the help page</a> for more information on how to configure Composer to download packages securely.'), + t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.'), + ], + ], + 'secure-http is off' => [ + ['secure-http' => FALSE], + [ + t('HTTPS must be enabled for Composer downloads. See <a href="/admin/help/package_manager#package-manager-requirements">the help page</a> for more information on how to configure Composer to download packages securely.'), + ], + ], + ]; + } + + /** + * Tests that invalid configuration links to online help, if available. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected validation error messages. + * + * @dataProvider providerLinkToOnlineHelp + */ + public function testLinkToOnlineHelp(array $config, array $expected_messages): void { + $this->enableModules(['help']); + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + + $result = ValidationResult::createError($expected_messages, t("Composer settings don't satisfy Package Manager's requirements.")); + $this->assertStatusCheckResults([$result]); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Ensure that any warnings arising from Composer settings (which we expect + // in this test) will not fail the test during tear-down. + $this->failureLogger->reset(); + parent::tearDown(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php new file mode 100644 index 000000000000..a7ea9f40512b --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php @@ -0,0 +1,189 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\Component\Utility\Bytes; +use Drupal\package_manager\Validator\DiskSpaceValidator; + +/** + * @covers \Drupal\package_manager\Validator\DiskSpaceValidator + * @group package_manager + * @internal + */ +class DiskSpaceValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testDiskSpaceValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerDiskSpaceValidation(): array { + // @see \Drupal\Tests\package_manager\Traits\ValidationTestTrait::resolvePlaceholdersInArrayValuesWithRealPaths() + $root = '<PROJECT_ROOT>'; + $vendor = '<VENDOR_DIR>'; + + $root_insufficient = t('Drupal root filesystem "<PROJECT_ROOT>" has insufficient space. There must be at least 1024 megabytes free.'); + $vendor_insufficient = t('Vendor filesystem "<VENDOR_DIR>" has insufficient space. There must be at least 1024 megabytes free.'); + $temp_insufficient = t('Directory "temp" has insufficient space. There must be at least 1024 megabytes free.'); + $summary = t("There is not enough disk space to create a stage directory."); + + return [ + 'shared, vendor and temp sufficient, root insufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, root and vendor insufficient, temp sufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, vendor and root sufficient, temp insufficient' => [ + TRUE, + [ + $root => '2G', + $vendor => '4G', + 'temp' => '1M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'shared, root and temp insufficient, vendor sufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2G', + 'temp' => '2M', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $temp_insufficient, + ], $summary), + ], + ], + 'not shared, root insufficient, vendor and temp sufficient' => [ + FALSE, + [ + $root => '5M', + $vendor => '1G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'not shared, vendor insufficient, root and temp sufficient' => [ + FALSE, + [ + $root => '2G', + $vendor => '10M', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$vendor_insufficient]), + ], + ], + 'not shared, root and vendor sufficient, temp insufficient' => [ + FALSE, + [ + $root => '1G', + $vendor => '2G', + 'temp' => '3M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'not shared, root and vendor insufficient, temp sufficient' => [ + FALSE, + [ + $root => '500M', + $vendor => '75M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $vendor_insufficient, + ], $summary), + ], + ], + ]; + } + + /** + * Tests disk space validation. + * + * @param bool $shared_disk + * Whether the root and vendor directories are on the same logical disk. + * @param array $free_space + * The free space that should be reported for various paths. The keys + * are the paths, and the values are the free space that should be reported, + * in a format that can be parsed by + * \Drupal\Component\Utility\Bytes::toNumber(). + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerDiskSpaceValidation + */ + public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void { + $free_space = array_flip($this->resolvePlaceholdersInArrayValuesWithRealPaths(array_flip($free_space))); + + /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */ + $validator = $this->container->get(DiskSpaceValidator::class); + $validator->sharedDisk = $shared_disk; + $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests disk space validation during pre-apply. + * + * @param bool $shared_disk + * Whether the root and vendor directories are on the same logical disk. + * @param array $free_space + * The free space that should be reported for various paths. The keys + * are the paths, and the values are the free space that should be reported, + * in a format that can be parsed by + * \Drupal\Component\Utility\Bytes::toNumber(). + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerDiskSpaceValidation + */ + public function testDiskSpaceValidationDuringPreApply(bool $shared_disk, array $free_space, array $expected_results): void { + $free_space = array_flip($this->resolvePlaceholdersInArrayValuesWithRealPaths(array_flip($free_space))); + + $this->addEventTestListener(function () use ($shared_disk, $free_space): void { + /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */ + $validator = $this->container->get(DiskSpaceValidator::class); + $validator->sharedDisk = $shared_disk; + $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space); + }); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php new file mode 100644 index 000000000000..7583f63d3404 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php @@ -0,0 +1,242 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\Validator\DuplicateInfoFileValidator + * @group package_manager + * @internal + */ +class DuplicateInfoFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testDuplicateInfoFilesInStage. + * + * @return mixed[][] + * The test cases. + */ + public static function providerDuplicateInfoFilesInStage(): array { + return [ + 'Duplicate info.yml files in stage' => [ + [ + '/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 2 instances of module.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + // Duplicate files in stage but having different extension which we don't + // care about. + 'Duplicate info files in stage' => [ + [ + '/my_file.info', + ], + [ + '/my_file.info', + '/modules/my_file.info', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/fixtures folder' => [ + [ + '/tests/fixtures/module.info.yml', + ], + [ + '/tests/fixtures/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/modules folder' => [ + [ + '/tests/modules/module.info.yml', + ], + [ + '/tests/modules/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/themes folder' => [ + [ + '/tests/themes/theme.info.yml', + ], + [ + '/tests/themes/theme.info.yml', + '/themes/theme.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/profiles folder' => [ + [ + '/tests/profiles/profile.info.yml', + ], + [ + '/tests/profiles/profile.info.yml', + '/profiles/profile.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage not present in active' => [ + [], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 2 instances of module.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Duplicate info.yml files in active' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + ], + [], + ], + 'Same number of info.yml files in active and stage' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Multiple duplicate info.yml files in stage' => [ + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + ], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Multiple duplicate info.yml files in stage not present in active' => [ + [], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Multiple duplicate info.yml files in stage with one info.yml file not present in active' => [ + [ + '/modules/module1/module1.info.yml', + ], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + ]; + } + + /** + * Tests that duplicate info.yml in stage raise an error. + * + * @param string[] $active_info_files + * An array of info.yml files in active directory. + * @param string[] $stage_info_files + * An array of info.yml files in stage directory. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * An array of expected results. + * + * @dataProvider providerDuplicateInfoFilesInStage + */ + public function testDuplicateInfoFilesInStage(array $active_info_files, array $stage_info_files, array $expected_results): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['composer/semver:^3']); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $stage_dir = $stage->getStageDirectory(); + foreach ($active_info_files as $active_info_file) { + $this->createFileAtPath($active_dir, $active_info_file); + } + foreach ($stage_info_files as $stage_info_file) { + $this->createFileAtPath($stage_dir, $stage_info_file); + } + try { + $stage->apply(); + $this->assertEmpty($expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->event->getResults()); + } + } + + /** + * Creates the file at the root directory. + * + * @param string $root_directory + * The base directory in which the file will be created. + * @param string $file_path + * The path of the file to create. + */ + private function createFileAtPath(string $root_directory, string $file_path): void { + $parts = explode(DIRECTORY_SEPARATOR, $file_path); + $filename = array_pop($parts); + $file_dir = str_replace($filename, '', $file_path); + $fs = new Filesystem(); + if (!file_exists($file_dir)) { + $fs->mkdir($root_directory . $file_dir); + } + file_put_contents($root_directory . $file_path, "name: SOME MODULE\ntype: module\n"); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php new file mode 100644 index 000000000000..c17cccf7a7ac --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Extension\Extension; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; + +/** + * @covers \Drupal\package_manager\Validator\EnabledExtensionsValidator + * @group package_manager + * @internal + */ +class EnabledExtensionsValidatorTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * Data provider for testExtensionRemoved(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerExtensionRemoved(): array { + $summary = t('The update cannot proceed because the following enabled Drupal extension was removed during the update.'); + return [ + 'module' => [ + [ + [ + 'name' => 'drupal/test_module2', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ], + ], + [ + ValidationResult::createError([t("'test_module2' module (provided by <code>drupal/test_module2</code>)")], $summary), + ], + ], + 'module and theme' => [ + [ + [ + 'name' => 'drupal/test_module1', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ], + [ + 'name' => 'drupal/test_theme', + 'version' => '1.3.1', + 'type' => 'drupal-theme', + ], + ], + [ + ValidationResult::createError([ + t("'test_module1' module (provided by <code>drupal/test_module1</code>)"), + t("'test_theme' theme (provided by <code>drupal/test_theme</code>)"), + ], t('The update cannot proceed because the following enabled Drupal extensions were removed during the update.')), + ], + ], + 'profile' => [ + [ + [ + 'name' => 'drupal/test_profile', + 'version' => '1.3.1', + 'type' => 'drupal-profile', + ], + ], + [ + ValidationResult::createError([t("'test_profile' profile (provided by <code>drupal/test_profile</code>)")], $summary), + ], + ], + 'theme' => [ + [ + [ + 'name' => 'drupal/test_theme', + 'version' => '1.3.1', + 'type' => 'drupal-theme', + ], + ], + [ + ValidationResult::createError([t("'test_theme' theme (provided by <code>drupal/test_theme</code>)")], $summary), + ], + ], + ]; + } + + /** + * Tests that error is raised if Drupal modules, profiles or themes are removed. + * + * @param array $packages + * Packages that will be added to the active directory, and removed from the + * stage directory. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerExtensionRemoved + */ + public function testExtensionRemoved(array $packages, array $expected_results): void { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $this->installComposerInstallers($project_root); + + $active_manipulator = new ActiveFixtureManipulator(); + $stage_manipulator = $this->getStageFixtureManipulator(); + foreach ($packages as $package) { + $active_manipulator->addPackage($package, FALSE, TRUE); + $stage_manipulator->removePackage($package['name']); + } + $active_manipulator->commitChanges(); + + foreach ($packages as $package) { + $extension_name = str_replace('drupal/', '', $package['name']); + $extension = self::createExtension($project_root, $package['type'], $extension_name); + + if ($extension->getType() === 'theme') { + /** @var \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler */ + $theme_handler = $this->container->get('theme_handler'); + $theme_handler->addTheme($extension); + $this->assertArrayHasKey($extension_name, $theme_handler->listInfo()); + } + else { + /** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */ + $module_handler = $this->container->get('module_handler'); + $module_list = $module_handler->getModuleList(); + $module_list[$extension_name] = $extension; + $module_handler->setModuleList($module_list); + $this->assertArrayHasKey($extension_name, $module_handler->getModuleList()); + } + } + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Returns a mocked extension object for a package. + * + * @param string $project_root + * The project root directory. + * @param string $package_type + * The package type (e.g., `drupal-module` or `drupal-theme`). + * @param string $extension_name + * The name of the extension. + * + * @return \Drupal\Core\Extension\Extension + * An extension object. + */ + private static function createExtension(string $project_root, string $package_type, string $extension_name): Extension { + $type = match ($package_type) { + 'drupal-theme' => 'theme', + 'drupal-profile' => 'profile', + default => 'module', + }; + $subdirectory = match ($type) { + 'theme' => 'themes', + 'profile' => 'profiles', + 'module' => 'modules', + }; + return new Extension($project_root, $type, "$subdirectory/contrib/$extension_name/$extension_name.info.yml"); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php new file mode 100644 index 000000000000..2e9a3909bf9c --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\EnvironmentSupportValidator; + +/** + * @covers \Drupal\package_manager\Validator\EnvironmentSupportValidator + * @group package_manager + * @internal + */ +class EnvironmentSupportValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests handling of an invalid URL in the environment support variable. + */ + public function testInvalidUrl(): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=broken/url.org'); + + $result = ValidationResult::createError([ + t('Package Manager is not supported by your environment.'), + ]); + foreach ([PreCreateEvent::class, StatusCheckEvent::class] as $event_class) { + $this->assertEventPropagationStopped($event_class, [$this->container->get(EnvironmentSupportValidator::class), 'validate']); + } + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests an invalid URL in the environment support variable during pre-apply. + */ + public function testInvalidUrlDuringPreApply(): void { + $this->addEventTestListener(function (): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=broken/url.org'); + }); + + $result = ValidationResult::createError([ + t('Package Manager is not supported by your environment.'), + ]); + + $this->assertEventPropagationStopped(PreApplyEvent::class, [$this->container->get(EnvironmentSupportValidator::class), 'validate']); + $this->assertResults([$result], PreApplyEvent::class); + } + + /** + * Tests that the validation message links to the provided URL. + */ + public function testValidUrl(): void { + $url = 'http://www.example.com'; + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=' . $url); + + $result = ValidationResult::createError([ + t('<a href=":url">Package Manager is not supported by your environment.</a>', [':url' => $url]), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that the validation message links to the provided URL during pre-apply. + */ + public function testValidUrlDuringPreApply(): void { + $url = 'http://www.example.com'; + $this->addEventTestListener(function () use ($url): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=' . $url); + }); + + $result = ValidationResult::createError([ + t('<a href=":url">Package Manager is not supported by your environment.</a>', [':url' => $url]), + ]); + $this->assertResults([$result], PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php new file mode 100644 index 000000000000..a41de4a92200 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; + +/** + * @coversDefaultClass \Drupal\package_manager\FailureMarker + * @group package_manager + * @internal + */ +class FailureMarkerTest extends PackageManagerKernelTestBase { + + /** + * @covers ::getMessage + * @testWith [true] + * [false] + */ + public function testGetMessageWithoutThrowable(bool $include_backtrace): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!')); + + $this->assertMatchesRegularExpression('/^Disastrous catastrophe!$/', $failure_marker->getMessage($include_backtrace)); + } + + /** + * @covers ::getMessage + * @testWith [true] + * [false] + */ + public function testGetMessageWithThrowable(bool $include_backtrace): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!'), new \Exception('Witchcraft!')); + + $expected_pattern = $include_backtrace + ? <<<REGEXP +/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft! +Backtrace: +#0 .*FailureMarkerTest->testGetMessageWithThrowable\(true\) +#1 .* +#2 .* +#3 .*/ +REGEXP + : '/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft!$/'; + $this->assertMatchesRegularExpression( + $expected_pattern, + $failure_marker->getMessage($include_backtrace) + ); + } + + /** + * Tests that an exception is thrown if the marker file contains invalid YAML. + * + * @covers ::assertNotExists + */ + public function testExceptionForInvalidYaml(): void { + $failure_marker = $this->container->get(FailureMarker::class); + // Write the failure marker with invalid YAML. + file_put_contents($failure_marker->getPath(), 'message : something message : something1'); + + $this->expectException(StageFailureMarkerException::class); + $this->expectExceptionMessage('Failure marker file exists but cannot be decoded.'); + $failure_marker->assertNotExists(); + } + + /** + * Tests that the failure marker can contain an exception message. + * + * @covers ::assertNotExists + */ + public function testAssertNotExists(): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Something wicked occurred here.'), new \Exception('Witchcraft!')); + + $this->expectException(StageFailureMarkerException::class); + $this->expectExceptionMessageMatches('/^Something wicked occurred here. Caused by Exception, with this message: Witchcraft!\nBacktrace:\n#0 .*/'); + $failure_marker->assertNotExists(); + } + + /** + * @covers ::getSubscribedEvents + * @covers ::excludeMarkerFile + */ + public function testMarkerFileIsExcluded(): void { + $event = new CollectPathsToExcludeEvent( + $this->createStage(), + $this->container->get(PathLocator::class), + $this->container->get(PathFactoryInterface::class), + ); + $this->container->get('event_dispatcher')->dispatch($event); + $this->assertContains('PACKAGE_MANAGER_FAILURE.yml', $event->getAll()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php b/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php new file mode 100644 index 000000000000..e7ed43228bbd --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php @@ -0,0 +1,161 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\PathLocator; +use Symfony\Component\Process\Process; + +/** + * Test that the 'fake-site' fixture is a valid starting point. + * + * @group package_manager + * @internal + */ +class FakeSiteFixtureTest extends PackageManagerKernelTestBase { + + /** + * Tests the complete stage life cycle using the 'fake-site' fixture. + */ + public function testLifeCycle(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([]); + // Ensure there are no validation errors after the stage lifecycle has been + // completed. + $this->assertStatusCheckResults([]); + } + + /** + * Tests calls to ComposerInspector class methods. + */ + public function testCallToComposerInspectorMethods(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $list = $inspector->getInstalledPackagesList($active_dir); + $this->assertNull($list->getPackageByDrupalProjectName('any_random_name')); + $this->assertFalse(isset($list['drupal/any_random_name'])); + } + + /** + * Tests if `setVersion` can be called on all packages in the fixture. + * + * @see \Drupal\fixture_manipulator\FixtureManipulator::setVersion() + */ + public function testCallToSetVersion(): void { + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $installed_packages = $inspector->getInstalledPackagesList($active_dir); + foreach (self::getExpectedFakeSitePackages() as $package_name) { + $this->assertArrayHasKey($package_name, $installed_packages); + $this->assertSame($installed_packages[$package_name]->version, '9.8.0'); + (new ActiveFixtureManipulator()) + ->setVersion($package_name, '11.1.0') + ->commitChanges(); + $list = $inspector->getInstalledPackagesList($active_dir); + $this->assertSame($list[$package_name]?->version, '11.1.0'); + } + } + + /** + * Tests if `removePackage` can be called on all packages in the fixture. + * + * @covers \Drupal\fixture_manipulator\FixtureManipulator::removePackage + */ + public function testCallToRemovePackage(): void { + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $expected_packages = self::getExpectedFakeSitePackages(); + $actual_packages = array_keys($inspector->getInstalledPackagesList($active_dir)->getArrayCopy()); + sort($actual_packages); + $this->assertSame($expected_packages, $actual_packages); + foreach (self::getExpectedFakeSitePackages() as $package_name) { + (new ActiveFixtureManipulator()) + ->removePackage($package_name, $package_name === 'drupal/core-dev') + ->commitChanges(); + array_shift($expected_packages); + $actual_package_names = array_keys($inspector->getInstalledPackagesList($active_dir)->getArrayCopy()); + sort($actual_package_names); + $this->assertSame($expected_packages, $actual_package_names); + } + + } + + /** + * Checks that the expected packages are installed in the fake site fixture. + */ + public function testExpectedPackages(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $installed_packages = $this->container->get(ComposerInspector::class) + ->getInstalledPackagesList($project_root) + ->getArrayCopy(); + ksort($installed_packages); + $this->assertSame($this->getExpectedFakeSitePackages(), array_keys($installed_packages)); + } + + /** + * Gets the expected packages in the `fake_site` fixture. + * + * @return string[] + * The package names. + */ + private static function getExpectedFakeSitePackages(): array { + $packages = [ + 'drupal/core', + 'drupal/core-recommended', + 'drupal/core-dev', + ]; + sort($packages); + return $packages; + } + + /** + * Tests that Composer show command can be used on the fixture. + */ + public function testComposerShow(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + (new ActiveFixtureManipulator()) + ->addPackage([ + 'type' => 'package', + 'version' => '1.2.3', + 'name' => 'any-org/any-package', + ]) + ->commitChanges(); + $process = new Process(['composer', 'show', '--format=json'], $active_dir); + $process->run(); + if ($error = $process->getErrorOutput()) { + $this->fail('Process error: ' . $error); + } + $output = json_decode($process->getOutput(), TRUE); + $package_names = array_map(fn (array $package) => $package['name'], $output['installed']); + $this->assertTrue(asort($package_names)); + $this->assertSame(['any-org/any-package', 'drupal/core', 'drupal/core-dev', 'drupal/core-recommended'], $package_names); + $list = $this->container->get(ComposerInspector::class)->getInstalledPackagesList($active_dir); + $list_packages_names = array_keys($list->getArrayCopy()); + $this->assertSame(['any-org/any-package', 'drupal/core', 'drupal/core-dev', 'drupal/core-recommended'], $list_packages_names); + } + + /** + * Tests that the fixture passes `composer validate`. + */ + public function testComposerValidate(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $process = new Process([ + 'composer', + 'validate', + '--check-lock', + '--with-dependencies', + '--no-interaction', + '--ansi', + '--no-cache', + ], $active_dir); + $process->mustRun(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php b/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php new file mode 100644 index 000000000000..566201f9135a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php @@ -0,0 +1,280 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait; +use Drupal\package_manager\PathLocator; + +/** + * @coversDefaultClass \Drupal\fixture_manipulator\FixtureManipulator + * + * @group package_manager + */ +class FixtureManipulatorTest extends PackageManagerKernelTestBase { + + use InstalledPackagesListTrait; + + /** + * The root directory of the test project. + * + * @var string + */ + private string $dir; + + /** + * The exception expected in ::tearDown() of this test. + * + * @var \Exception + */ + private \Exception $expectedTearDownException; + + /** + * The Composer inspector service. + * + * @var \Drupal\package_manager\ComposerInspector + */ + private ComposerInspector $inspector; + + /** + * The original fixture package list at the start of the test. + * + * @var \Drupal\package_manager\InstalledPackagesList + */ + private InstalledPackagesList $originalFixturePackages; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $this->inspector = $this->container->get(ComposerInspector::class); + + $manipulator = new ActiveFixtureManipulator(); + $manipulator + ->addPackage([ + 'name' => 'my/package', + 'type' => 'library', + 'version' => '1.2.3', + ]) + ->addPackage( + [ + 'name' => 'my/dev-package', + 'version' => '2.1.0', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + $this->originalFixturePackages = $this->inspector->getInstalledPackagesList($this->dir); + } + + /** + * @covers ::addPackage + */ + public function testAddPackage(): void { + // Packages cannot be added without a name. + foreach (['name', 'type'] as $require_key) { + // Make a package that is missing the required key. + $package = array_diff_key( + [ + 'name' => 'Any old name', + 'type' => 'Any old type', + ], + [$require_key => ''] + ); + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage($package) + ->commitChanges(); + $this->fail("Adding a package without the '$require_key' should raise an error."); + } + catch (\UnexpectedValueException $e) { + $this->assertSame("The '$require_key' is required when calling ::addPackage().", $e->getMessage()); + } + } + + // We should get a helpful error if the name is not a valid package name. + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage([ + 'name' => 'my_drupal_module', + 'type' => 'drupal-module', + ]) + ->commitChanges(); + $this->fail('Trying to add a package with an invalid name should raise an error.'); + } + catch (\UnexpectedValueException $e) { + $this->assertSame("'my_drupal_module' is not a valid package name.", $e->getMessage()); + } + + // We should not be able to add an existing package. + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage([ + 'name' => 'my/package', + 'type' => 'library', + ]) + ->commitChanges(); + $this->fail('Trying to add an existing package should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString("Expected package 'my/package' to not be installed, but it was.", $e->getMessage()); + } + // Ensure that none of the failed calls to ::addPackage() changed the installed + // packages. + $this->assertPackageListsEqual($this->originalFixturePackages, $this->inspector->getInstalledPackagesList($this->dir)); + $root_info = $this->inspector->getRootPackageInfo($this->dir); + $this->assertSame( + ['drupal/core-dev', 'my/dev-package'], + array_keys($root_info['devRequires']) + ); + } + + /** + * @covers ::modifyPackageConfig + */ + public function testModifyPackageConfig(): void { + // Assert ::modifyPackage() works with a package in an existing fixture not + // created by ::addPackage(). + $decode_packages_json = function (): array { + return json_decode(file_get_contents($this->dir . "/packages.json"), TRUE, flags: JSON_THROW_ON_ERROR); + }; + $original_packages_json = $decode_packages_json(); + (new ActiveFixtureManipulator()) + // @see ::setUp() + ->modifyPackageConfig('my/dev-package', '2.1.0', ['description' => 'something else'], TRUE) + ->commitChanges(); + // Verify that the package is indeed properly installed. + $this->assertSame('2.1.0', $this->inspector->getInstalledPackagesList($this->dir)['my/dev-package']?->version); + // Verify that the original exists, but has no description. + $this->assertSame('my/dev-package', $original_packages_json['packages']['my/dev-package']['2.1.0']['name']); + $this->assertArrayNotHasKey('description', $original_packages_json); + // Verify that the description was updated. + $this->assertSame('something else', $decode_packages_json()['packages']['my/dev-package']['2.1.0']['description']); + + (new ActiveFixtureManipulator()) + // Add a key to an existing package. + ->modifyPackageConfig('my/package', '1.2.3', ['extra' => ['foo' => 'bar']]) + // Change a key in an existing package. + ->setVersion('my/dev-package', '3.2.1', TRUE) + ->commitChanges(); + $this->assertSame(['foo' => 'bar'], $decode_packages_json()['packages']['my/package']['1.2.3']['extra']); + $this->assertSame('3.2.1', $this->inspector->getInstalledPackagesList($this->dir)['my/dev-package']?->version); + } + + /** + * @covers ::removePackage + */ + public function testRemovePackage(): void { + // We should not be able to remove a package that's not installed. + try { + (new ActiveFixtureManipulator()) + ->removePackage('junk/drawer') + ->commitChanges(); + $this->fail('Removing a non-existent package should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString('junk/drawer is not required in your composer.json and has not been remove', $e->getMessage()); + } + + // Remove the 2 packages that were added in ::setUp(). + (new ActiveFixtureManipulator()) + ->removePackage('my/package') + ->removePackage('my/dev-package', TRUE) + ->commitChanges(); + $expected_packages = $this->originalFixturePackages->getArrayCopy(); + unset($expected_packages['my/package'], $expected_packages['my/dev-package']); + $expected_list = new InstalledPackagesList($expected_packages); + $this->assertPackageListsEqual($expected_list, $this->inspector->getInstalledPackagesList($this->dir)); + $root_info = $this->inspector->getRootPackageInfo($this->dir); + $this->assertSame( + ['drupal/core-dev'], + array_keys($root_info['devRequires']) + ); + } + + /** + * Test that an exception is thrown if ::commitChanges() is not called. + */ + public function testActiveManipulatorNoCommitError(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('commitChanges() must be called.'); + (new ActiveFixtureManipulator()) + ->setVersion('drupal/core', '1.2.3'); + } + + /** + * @covers ::addDotGitFolder + */ + public function testAddDotGitFolder(): void { + $path_locator = $this->container->get(PathLocator::class); + $project_root = $path_locator->getProjectRoot(); + $this->assertFalse(is_dir($project_root . "/relative/path/.git")); + // We should not be able to add a git folder to a non-existing directory. + try { + (new FixtureManipulator()) + ->addDotGitFolder($project_root . "/relative/path") + ->commitChanges($project_root); + $this->fail('Trying to create a .git directory that already exists should raise an error.'); + } + catch (\LogicException $e) { + $this->assertSame('No directory exists at ' . $project_root . '/relative/path.', $e->getMessage()); + } + mkdir($project_root . "/relative/path", 0777, TRUE); + $fixture_manipulator = (new FixtureManipulator()) + ->addPackage([ + 'name' => 'relative/project_path', + 'type' => 'drupal-module', + ]) + ->addDotGitFolder($path_locator->getVendorDirectory() . "/relative/project_path") + ->addDotGitFolder($project_root . "/relative/path"); + $this->assertTrue(!is_dir($project_root . "/relative/project_path/.git")); + $fixture_manipulator->commitChanges($project_root); + $this->assertTrue(is_dir($project_root . "/relative/path/.git")); + // We should not be able to create already existing directory. + try { + (new FixtureManipulator()) + ->addDotGitFolder($project_root . "/relative/path") + ->commitChanges($project_root); + $this->fail('Trying to create a .git directory that already exists should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString("A .git directory already exists at " . $project_root, $e->getMessage()); + } + } + + /** + * Tests that the stage manipulator throws an exception if not committed. + */ + public function testStagedFixtureNotCommitted(): void { + $this->expectedTearDownException = new \LogicException('The StageFixtureManipulator has arguments that were not cleared. This likely means that the PostCreateEvent was never fired.'); + $this->getStageFixtureManipulator()->setVersion('any-org/any-package', '3.2.1'); + } + + /** + * {@inheritdoc} + * + * @todo Remove the line below when https://github.com/phpstan/phpstan-phpunit/issues/187 is fixed. + * @phpstan-ignore-next-line + */ + protected function tearDown(): void { + try { + parent::tearDown(); + } + catch (\Exception $exception) { + if (!(get_class($exception) === get_class($this->expectedTearDownException) && $exception->getMessage() === $this->expectedTearDownException->getMessage())) { + throw $exception; + } + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php b/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php new file mode 100644 index 000000000000..d0fb6b37827a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php @@ -0,0 +1,170 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Serialization\Yaml; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\package_manager\PathLocator; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends PackageManagerKernelTestBase { + + /** + * @covers \Drupal\package_manager\InstalledPackage::getProjectName + * @covers ::getPackageByDrupalProjectName + */ + public function testPackageByDrupalProjectName(): void { + // In getPackageByDrupalProjectName(), we don't expect that projects will be + // in the "correct" places -- for example, we don't assume that modules will + // be in the `modules` directory, or themes will be the `themes` directory. + // So, in this test, we ensure that flexibility works by just throwing all + // the projects into a single `projects` directory. + $projects_path = $this->container->get(PathLocator::class) + ->getProjectRoot() . '/projects'; + + // The project name does not match the package name, and the project + // physically exists. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/theme_project') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/a_package' => InstalledPackage::createFromArray([ + 'name' => 'drupal/a_package', + 'version' => '1.0.0', + 'type' => 'drupal-theme', + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertSame($list['drupal/a_package'], $list->getPackageByDrupalProjectName('theme_project')); + + // The project physically exists, but the package path points to the wrong + // place. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/example3') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/example3' => InstalledPackage::createFromArray([ + 'name' => 'drupal/example3', + 'version' => '1.0.0', + 'type' => 'drupal-module', + // This path exists, but it doesn't contain the `example3` project. + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('example3')); + + // The project does not physically exist, which means it must be a metapackage. + $list = new InstalledPackagesList([ + 'drupal/missing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/missing', + 'version' => '1.0.0', + 'type' => 'metapackage', + 'path' => NULL, + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('missing')); + + // The project physically exists in a subdirectory of the package. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/grab_bag/modules/module_in_subdirectory') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/grab_bag' => InstalledPackage::createFromArray([ + 'name' => 'drupal/grab_bag', + 'version' => '1.0.0', + 'type' => 'drupal-profile', + 'path' => $projects_path . '/grab_bag', + ]), + ]); + $this->assertSame($list['drupal/grab_bag'], $list->getPackageByDrupalProjectName('module_in_subdirectory')); + + // The package name matches a project that physically exists, but the + // package vendor is not `drupal`. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/not_from_drupal') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'vendor/not_from_drupal' => InstalledPackage::createFromArray([ + 'name' => 'vendor/not_from_drupal', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/not_from_drupal', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('not_from_drupal')); + + // These package names match physically existing projects, and they are + // from the `drupal` vendor, but they're not supported package types. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/custom_module') + ->addProjectAtPath('projects/custom_theme') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/custom_module' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_module', + 'version' => '1.0.0', + 'type' => 'drupal-custom-module', + 'path' => $projects_path . '/custom_module', + ]), + 'drupal/custom_theme' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_theme', + 'version' => '1.0.0', + 'type' => 'drupal-custom-theme', + 'path' => $projects_path . '/custom_theme', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('custom_module')); + $this->assertNull($list->getPackageByDrupalProjectName('custom_theme')); + + // The `project` key has been removed from the info file. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/no_project_key') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/no_project_key' => InstalledPackage::createFromArray([ + 'name' => 'drupal/no_project_key', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/no_project_key', + ]), + ]); + $info_file = $list['drupal/no_project_key']->path . '/no_project_key.info.yml'; + $this->assertFileIsWritable($info_file); + $info = Yaml::decode(file_get_contents($info_file)); + unset($info['project']); + file_put_contents($info_file, Yaml::encode($info)); + $this->assertNull($list->getPackageByDrupalProjectName('no_project_key')); + + // The project name is repeated. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/duplicate_project') + ->addProjectAtPath('projects/repeat/duplicate_project') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/test_project1' => InstalledPackage::createFromArray([ + 'name' => 'drupal/test_project1', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/duplicate_project', + ]), + 'drupal/test_project2' => InstalledPackage::createFromArray([ + 'name' => 'drupal/test_project2', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/repeat/duplicate_project', + ]), + ]); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("Project 'duplicate_project' was found in packages 'drupal/test_project1' and 'drupal/test_project2'."); + $list->getPackageByDrupalProjectName('duplicate_project'); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php new file mode 100644 index 000000000000..bcd32f639dfb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\Validator\LockFileValidator; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_bypass\NoOpStager; +use Prophecy\Argument; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\LockFileValidator + * @group package_manager + * @internal + */ +class LockFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * The path of the active directory in the test project. + * + * @var string + */ + private $activeDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->activeDir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Temporarily mock the Composer inspector to prevent it from complaining + // over the lack of a lock file if it's invoked by other validators. + $inspector = $this->prophesize(ComposerInspector::class); + $arguments = Argument::cetera(); + $inspector->getConfig('allow-plugins', $arguments)->willReturn('[]'); + $inspector->getConfig('secure-http', $arguments)->willReturn('true'); + $inspector->getConfig('disable-tls', $arguments)->willReturn('false'); + $inspector->getConfig('extra', $arguments)->willReturn('{}'); + $inspector->getConfig('minimum-stability', $arguments)->willReturn('stable'); + $inspector->getInstalledPackagesList($arguments)->willReturn(new InstalledPackagesList()); + $inspector->getAllowPluginsConfig($arguments)->willReturn([]); + $inspector->validate($arguments); + $inspector->getRootPackageInfo($arguments)->willReturn([]); + $container->set(ComposerInspector::class, $inspector->reveal()); + } + + /** + * Tests that if no active lock file exists, a stage cannot be created. + * + * @covers ::storeHash + */ + public function testCreateWithNoLock(): void { + unlink($this->activeDir . '/composer.lock'); + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $lock_file_path = $project_root . DIRECTORY_SEPARATOR . 'composer.lock'; + $no_lock = ValidationResult::createError([ + t('The active lock file (@file) does not exist.', ['@file' => $lock_file_path]), + ]); + $stage = $this->assertResults([$no_lock], PreCreateEvent::class); + // The stage was not created successfully, so the status check should be + // clear. + $this->assertStatusCheckResults([], $stage); + } + + /** + * Tests that if an active lock file exists, a stage can be created. + * + * @covers ::storeHash + * @covers ::deleteHash + */ + public function testCreateWithLock(): void { + $this->assertResults([]); + + // Change the lock file to ensure the stored hash of the previous version + // has been deleted. + file_put_contents($this->activeDir . '/composer.lock', '{"changed": true}'); + $this->assertResults([]); + } + + /** + * Tests validation when the lock file has changed. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileChanged(string $event_class): void { + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener changes lock file before the validator + // runs. + $this->addEventTestListener(function () { + $lock = json_decode(file_get_contents($this->activeDir . '/composer.lock'), TRUE, flags: JSON_THROW_ON_ERROR); + $lock['extra']['key'] = 'value'; + file_put_contents($this->activeDir . '/composer.lock', json_encode($lock, JSON_THROW_ON_ERROR)); + }, $event_class); + $result = ValidationResult::createError([ + t('Unexpected changes were detected in the active lock file (@file), which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.', + ['@file' => $this->activeDir . '/composer.lock']), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], $event_class); + // A status check should agree that there is an error here. + $this->assertStatusCheckResults([$result], $stage); + } + + /** + * Tests validation when the lock file is deleted. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileDeleted(string $event_class): void { + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes lock file before the validator + // runs. + $this->addEventTestListener(function () { + unlink($this->activeDir . '/composer.lock'); + }, $event_class); + $result = ValidationResult::createError([ + t('The active lock file (@file) does not exist.', [ + '@file' => $this->activeDir . '/composer.lock', + ]), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], $event_class); + // A status check should agree that there is an error here. + $this->assertStatusCheckResults([$result], $stage); + } + + /** + * Tests exception when a stored hash of the active lock file is unavailable. + * + * @dataProvider providerValidateStageEvents + */ + public function testNoStoredHash(string $event_class): void { + $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'KEY'); + $key = $reflector->getValue(); + + // Add a listener with an extremely high priority to the same event that + // should throw an exception. Because the validator uses the default + // priority of 0, this listener deletes stored hash before the validator + // runs. + $this->addEventTestListener(function () use ($key) { + $this->container->get('keyvalue') + ->get('package_manager') + ->delete($key); + }, $event_class); + + $stage = $this->createStage(); + $stage->create(); + try { + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + } + catch (StageException $e) { + $this->assertSame(\LogicException::class, $e->getPrevious()::class); + $this->assertSame('Stored hash key deleted.', $e->getMessage()); + } + } + + /** + * Tests validation when the staged and active lock files are identical. + */ + public function testApplyWithNoChange(): void { + // Leave the staged lock file alone. + NoOpStager::setLockFileShouldChange(FALSE); + + $result = ValidationResult::createError([ + t('There appear to be no pending Composer operations because the active lock file (<PROJECT_ROOT>/composer.lock) and the staged lock file (<STAGE_DIR>/composer.lock) are identical.'), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], PreApplyEvent::class); + // A status check shouldn't produce raise any errors, because it's only + // during pre-apply that we care if there are any pending Composer + // operations. + $this->assertStatusCheckResults([], $stage); + } + + /** + * Tests StatusCheckEvent when the stage is available. + */ + public function testStatusCheckAvailableStage():void { + $this->assertStatusCheckResults([]); + } + + /** + * Data provider for test methods that validate the stage directory. + * + * @return string[][] + * The test cases. + */ + public static function providerValidateStageEvents(): array { + return [ + 'pre-require' => [ + PreRequireEvent::class, + ], + 'pre-apply' => [ + PreApplyEvent::class, + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php new file mode 100644 index 000000000000..24084014126d --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\MultisiteValidator + * @group package_manager + * @internal + */ +class MultisiteValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testMultisite(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerMultisite(): array { + return [ + 'sites.php present and listing multiple sites' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'default'; +// Site 2: the shop. +$sites['shop.example.com'] = 'shop'; +PHP, + [ + ValidationResult::createError([ + t('Drupal multisite is not supported by Package Manager.'), + ]), + ], + ], + 'sites.php present and listing single site' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'default'; +PHP, + [], + ], + 'sites.php present and listing multiple aliases for a single site' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'example'; +// Alias for site 1! +$sites['example.dev'] = 'example'; +PHP, + [], + ], + 'sites.php absent' => [ + NULL, + [], + ], + ]; + } + + /** + * Tests that Package Manager flags an error if run in a multisite. + * + * @param string|null $sites_php + * The sites.php contents to write, if any. If NULL, no sites.php will be + * created. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerMultisite + */ + public function testMultisite(?string $sites_php, array $expected_results = []): void { + if ($sites_php) { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + file_put_contents($project_root . '/sites/sites.php', $sites_php); + } + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests that an error is flagged if run in a multisite during pre-apply. + * + * @param string|null $sites_php + * The sites.php contents to write, if any. If NULL, no sites.php will be + * created. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerMultisite + */ + public function testMultisiteDuringPreApply(?string $sites_php, array $expected_results = []): void { + $this->addEventTestListener(function () use ($sites_php): void { + if ($sites_php) { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + file_put_contents($project_root . '/sites/sites.php', $sites_php); + } + }); + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php new file mode 100644 index 000000000000..93360a5c5006 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php @@ -0,0 +1,159 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\SupportedReleaseValidator; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; + +/** + * @covers \Drupal\package_manager\Validator\OverwriteExistingPackagesValidator + * @group package_manager + * @internal + */ +class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // In this test, we don't care whether the updated projects are secure and + // supported. + $this->disableValidators[] = SupportedReleaseValidator::class; + parent::setUp(); + + $this->installComposerInstallers($this->container->get(PathLocator::class)->getProjectRoot()); + } + + /** + * Tests that new installed packages overwrite existing directories. + * + * The fixture simulates a scenario where the active directory has four + * modules installed: module_1, module_2, module_5 and module_6. None of them + * are managed by Composer. These modules will be moved into the stage + * directory by the 'package_manager_bypass' module. + */ + public function testNewPackagesOverwriteExisting(): void { + (new ActiveFixtureManipulator()) + ->addProjectAtPath('modules/module_1') + ->addProjectAtPath('modules/module_2') + ->addProjectAtPath('modules/module_5') + ->addProjectAtPath('modules/module_6') + ->commitChanges(); + $stage_manipulator = $this->getStageFixtureManipulator(); + + $installer_paths = []; + // module_1 and module_2 will raise errors because they would overwrite + // non-Composer managed paths in the active directory. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_1', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_1'] = ['drupal/other_module_1']; + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_2', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE, + ); + $installer_paths['modules/module_2'] = ['drupal/other_module_2']; + + // module_3 will cause no problems, since it doesn't exist in the active + // directory at all. + $stage_manipulator->addPackage([ + 'name' => 'drupal/other_module_3', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE, + ); + $installer_paths['modules/module_3'] = ['drupal/other_module_3']; + + // module_4 doesn't exist in the active directory but the 'install_path' as + // known to Composer in the staged directory collides with module_6 in the + // active directory which will cause an error. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/module_4', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_6'] = ['drupal/module_4']; + + // module_5_different_path will not cause a problem, even though its package + // name is drupal/module_5, because its project name and path in the stage + // directory differ from the active directory. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_5', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_5_different_path'] = ['drupal/other_module_5']; + + // Set the installer path config in the active directory this will be + // copied to the stage directory where we install the packages. + $this->setInstallerPaths($installer_paths, $this->container->get(PathLocator::class)->getProjectRoot()); + + // Add a package without an install_path set which will not raise an error. + // The most common example of this in the Drupal ecosystem is a submodule. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/sub-module', + 'version' => '1.3.0', + 'type' => 'metapackage', + ], + FALSE, + TRUE + ); + $inspector = $this->container->get(ComposerInspector::class); + $listener = function (PostCreateEvent $event) use ($inspector) { + $list = $inspector->getInstalledPackagesList($event->stage->getStageDirectory()); + $this->assertArrayHasKey('drupal/sub-module', $list->getArrayCopy()); + $this->assertArrayHasKey('drupal/other_module_1', $list->getArrayCopy()); + // Confirm that metapackage will have a NULL install path. + $this->assertNull($list['drupal/sub-module']->path); + // Confirm another package has specified install path. + $this->assertSame($list['drupal/other_module_1']->path, $event->stage->getStageDirectory() . '/modules/module_1'); + }; + $this->addEventTestListener($listener, PostCreateEvent::class); + + $expected_results = [ + ValidationResult::createError([ + t('The new package drupal/module_4 will be installed in the directory /modules/module_6, which already exists but is not managed by Composer.'), + ]), + ValidationResult::createError([ + t('The new package drupal/other_module_1 will be installed in the directory /modules/module_1, which already exists but is not managed by Composer.'), + ]), + ValidationResult::createError([ + t('The new package drupal/other_module_2 will be installed in the directory /modules/module_2, which already exists but is not managed by Composer.'), + ]), + ]; + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php new file mode 100644 index 000000000000..ed7b5a7f3779 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -0,0 +1,520 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Site\Settings; +use Drupal\fixture_manipulator\StageFixtureManipulator; +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\Validator\DiskSpaceValidator; +use Drupal\package_manager\StageBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use Drupal\Tests\package_manager\Traits\ComposerStagerTestTrait; +use Drupal\Tests\package_manager\Traits\FixtureManipulatorTrait; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +use Drupal\Tests\package_manager\Traits\ValidationTestTrait; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Base class for kernel tests of Package Manager's functionality. + * + * @internal + */ +abstract class PackageManagerKernelTestBase extends KernelTestBase { + + use AssertPreconditionsTrait; + use ComposerStagerTestTrait; + use FixtureManipulatorTrait; + use FixtureUtilityTrait; + use StatusCheckTrait; + use ValidationTestTrait; + + /** + * The mocked HTTP client that returns metadata about available updates. + * + * We need to preserve this as a class property so that we can re-inject it + * into the container when a rebuild is triggered by module installation. + * + * @var \GuzzleHttp\Client + * + * @see ::register() + */ + private $client; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'fixture_manipulator', + 'package_manager', + 'package_manager_bypass', + 'system', + 'update', + 'update_test', + ]; + + /** + * The service IDs of any validators to disable. + * + * @var string[] + */ + protected $disableValidators = []; + + /** + * The test root directory, if any, created by ::createTestProject(). + * + * @var string|null + * + * @see ::createTestProject() + * @see ::tearDown() + */ + protected ?string $testProjectRoot = NULL; + + /** + * The Symfony filesystem class. + * + * @var \Symfony\Component\Filesystem\Filesystem + */ + private Filesystem $fileSystem; + + /** + * A logger that will fail the test if Package Manager logs any errors. + * + * @var \ColinODell\PsrTestLogger\TestLogger + * + * @see ::tearDown() + */ + protected TestLogger $failureLogger; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('package_manager'); + + $this->fileSystem = new Filesystem(); + $this->createTestProject(); + + // The Update module's default configuration must be installed for our + // fake release metadata to be fetched, and the System module's to ensure + // the site has a name. + $this->installConfig(['system', 'update']); + + // Make the update system think that all of System's post-update functions + // have run. + $this->registerPostUpdateFunctions(); + + // Ensure we can fail the test if any warnings, or worse, are logged by + // Package Manager. + // @see ::tearDown() + $this->failureLogger = new TestLogger(); + $this->container->get('logger.channel.package_manager') + ->addLogger($this->failureLogger); + } + + /** + * {@inheritdoc} + */ + protected function enableModules(array $modules): void { + parent::enableModules($modules); + $this->registerPostUpdateFunctions(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // If we previously set up a mock HTTP client in ::setReleaseMetadata(), + // re-inject it into the container. + if ($this->client) { + $container->set('http_client', $this->client); + } + + // When the test project is used, the disk space validator is replaced with + // a mock. When staged changes are applied, the container is rebuilt, which + // destroys the mocked service and can cause unexpected side effects. The + // 'persist' tag prevents the mock from being destroyed during a container + // rebuild. + // @see ::createTestProject() + $container->getDefinition(DiskSpaceValidator::class)->addTag('persist'); + + // Ensure that our failure logger will survive container rebuilds. + $container->getDefinition('logger.channel.package_manager') + ->addTag('persist'); + + array_walk($this->disableValidators, $container->removeDefinition(...)); + } + + /** + * Creates a stage object for testing purposes. + * + * @return \Drupal\Tests\package_manager\Kernel\TestStage + * A stage object, with test-only modifications. + */ + protected function createStage(): TestStage { + return new TestStage( + $this->container->get(PathLocator::class), + $this->container->get(BeginnerInterface::class), + $this->container->get(StagerInterface::class), + $this->container->get(CommitterInterface::class), + $this->container->get(QueueFactory::class), + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared'), + $this->container->get('datetime.time'), + $this->container->get(PathFactoryInterface::class), + $this->container->get(FailureMarker::class) + ); + } + + /** + * Asserts validation results are returned from a stage life cycle event. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param string|null $event_class + * (optional) The class of the event which should return the results. Must + * be passed if $expected_results is not empty. + * + * @return \Drupal\package_manager\StageBase + * The stage that was used to collect the validation results. + */ + protected function assertResults(array $expected_results, ?string $event_class = NULL): StageBase { + $stage = $this->createStage(); + + try { + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $stage->destroy(); + + // If we did not get an exception, ensure we didn't expect any results. + $this->assertValidationResultsEqual([], $expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertInstanceOf($event_class, $e->event); + $this->assertExpectedResultsFromException($expected_results, $e); + } + return $stage; + } + + /** + * Asserts validation results are returned from the status check event. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param \Drupal\Tests\package_manager\Kernel\TestStage|null $stage + * (optional) The test stage to use to create the status check event. If + * none is provided a new stage will be created. + */ + protected function assertStatusCheckResults(array $expected_results, ?StageBase $stage = NULL): void { + $actual_results = $this->runStatusCheck($stage ?? $this->createStage(), $this->container->get('event_dispatcher')); + $this->assertValidationResultsEqual($expected_results, $actual_results); + } + + /** + * Marks all pending post-update functions as completed. + * + * Since kernel tests don't normally install modules and register their + * updates, this method makes sure that we are testing from a clean, fully + * up-to-date state. + */ + protected function registerPostUpdateFunctions(): void { + static $updates = []; + $updates = array_merge($updates, $this->container->get('update.post_update_registry') + ->getPendingUpdateFunctions()); + + $this->container->get('keyvalue') + ->get('post_update') + ->set('existing_updates', $updates); + } + + /** + * Creates a test project. + * + * This will create a temporary uniques root directory and then creates two + * directories in it: + * 'active', which is the active directory containing a fake Drupal code base, + * and 'stage', which is the root directory used to stage changes. The path + * locator service will also be mocked so that it points to the test project. + * + * @param string|null $source_dir + * (optional) The path of a directory which should be copied into the + * test project and used as the active directory. + */ + protected function createTestProject(?string $source_dir = NULL): void { + static $called; + if (isset($called)) { + throw new \LogicException('Only one test project should be created per kernel test method!'); + } + else { + $called = TRUE; + } + + $this->testProjectRoot = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'package_manager_testing_root' . $this->databasePrefix; + if (is_dir($this->testProjectRoot)) { + $this->fileSystem->remove($this->testProjectRoot); + } + $this->fileSystem->mkdir($this->testProjectRoot); + + // Create the active directory and copy its contents from a fixture. + $active_dir = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'active'; + $this->assertTrue(mkdir($active_dir)); + static::copyFixtureFilesTo($source_dir ?? __DIR__ . '/../../fixtures/fake_site', $active_dir); + + // Removing 'vfs://root/' from site path set in + // \Drupal\KernelTests\KernelTestBase::setUpFilesystem as we don't use vfs. + $test_site_path = str_replace('vfs://root/', '', $this->siteDirectory); + + // Copy directory structure from vfs site directory to our site directory. + $this->fileSystem->mirror($this->siteDirectory, $active_dir . DIRECTORY_SEPARATOR . $test_site_path); + + // Override siteDirectory to point to root/active/... instead of root/... . + $this->siteDirectory = $active_dir . DIRECTORY_SEPARATOR . $test_site_path; + + // Override KernelTestBase::setUpFilesystem's Settings object. + $settings = Settings::getInstance() ? Settings::getAll() : []; + $settings['file_public_path'] = $this->siteDirectory . '/files'; + $settings['config_sync_directory'] = $this->siteDirectory . '/files/config/sync'; + new Settings($settings); + + // Create a stage root directory alongside the active directory. + $staging_root = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'stage'; + $this->assertTrue(mkdir($staging_root)); + + // Ensure the path locator points to the test project. We assume that is its + // own web root and the vendor directory is at its top level. + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + $path_locator->setPaths($active_dir, $active_dir . '/vendor', '', $staging_root); + + // This validator will persist through container rebuilds. + // @see ::register() + $validator = new TestDiskSpaceValidator($path_locator); + // By default, the validator should report that the root, vendor, and + // temporary directories have basically infinite free space. + $validator->freeSpace = [ + $path_locator->getProjectRoot() => PHP_INT_MAX, + $path_locator->getVendorDirectory() => PHP_INT_MAX, + $validator->temporaryDirectory() => PHP_INT_MAX, + ]; + $this->container->set(DiskSpaceValidator::class, $validator); + } + + /** + * Sets the current (running) version of core, as known to the Update module. + * + * @todo Remove this function with use of the trait from the Update module in + * https://drupal.org/i/3348234. + * + * @param string $version + * The current version of core. + */ + protected function setCoreVersion(string $version): void { + $this->config('update_test.settings') + ->set('system_info.#all.version', $version) + ->save(); + } + + /** + * Sets the release metadata file to use when fetching available updates. + * + * @param string[] $files + * The paths of the XML metadata files to use, keyed by project name. + */ + protected function setReleaseMetadata(array $files): void { + $responses = []; + + foreach ($files as $project => $file) { + $metadata = Utils::tryFopen($file, 'r'); + $responses["/release-history/$project/current"] = new Response(200, [], Utils::streamFor($metadata)); + } + $callable = function (RequestInterface $request) use ($responses): Response { + return $responses[$request->getUri()->getPath()] ?? new Response(404); + }; + + // The mock handler's queue consist of same callable as many times as the + // number of requests we expect to be made for update XML because it will + // retrieve one item off the queue for each request. + // @see \GuzzleHttp\Handler\MockHandler::__invoke() + $handler = new MockHandler(array_fill(0, 100, $callable)); + $this->client = new Client([ + 'handler' => HandlerStack::create($handler), + ]); + $this->container->set('http_client', $this->client); + } + + /** + * Adds an event listener on an event for testing purposes. + * + * @param callable $listener + * The listener to add. + * @param string $event_class + * (optional) The event to listen to. Defaults to PreApplyEvent. + * @param int $priority + * (optional) The priority. Defaults to PHP_INT_MAX. + */ + protected function addEventTestListener(callable $listener, string $event_class = PreApplyEvent::class, int $priority = PHP_INT_MAX): void { + $this->container->get('event_dispatcher') + ->addListener($event_class, $listener, $priority); + } + + /** + * Asserts event propagation is stopped by a certain event subscriber. + * + * @param string $event_class + * The event during which propagation is expected to stop. + * @param callable $expected_propagation_stopper + * The event subscriber (which subscribes to the given event class) which is + * expected to stop propagation. This event subscriber must have been + * registered by one of the installed Drupal module. + */ + protected function assertEventPropagationStopped(string $event_class, callable $expected_propagation_stopper): void { + $priority = $this->container->get('event_dispatcher')->getListenerPriority($event_class, $expected_propagation_stopper); + // Ensure the event subscriber was actually a listener for the event. + $this->assertIsInt($priority); + // Add a listener with a priority that is 1 less than priority of the + // event subscriber. This listener would be called after + // $expected_propagation_stopper if the event propagation was not stopped + // and cause the test to fail. + $this->addEventTestListener(function () use ($event_class): void { + $this->fail('Event propagation should have been stopped during ' . $event_class . '.'); + }, $event_class, $priority - 1); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Delete the test project root, which contains the active directory and + // the stage directory. First, make it writable in case any permissions were + // changed during the test. + if ($this->testProjectRoot) { + $this->fileSystem->chmod($this->testProjectRoot, 0777, 0000, TRUE); + $this->fileSystem->remove($this->testProjectRoot); + } + + StageFixtureManipulator::handleTearDown(); + + // Ensure no warnings (or worse) were logged by Package Manager. + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::EMERGENCY), 'Package Manager logged emergencies.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ALERT), 'Package Manager logged alerts.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::CRITICAL), 'Package Manager logged critical errors.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ERROR), 'Package Manager logged errors.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::WARNING), 'Package Manager logged warnings.'); + parent::tearDown(); + } + + /** + * Asserts that a StageEventException has a particular set of results. + * + * @param array $expected_results + * The expected results. + * @param \Drupal\package_manager\Exception\StageEventException $exception + * The exception. + */ + protected function assertExpectedResultsFromException(array $expected_results, StageEventException $exception): void { + $event = $exception->event; + $this->assertInstanceOf(PreOperationStageEvent::class, $event); + + $stage = $event->stage; + $stage_dir = $stage->stageDirectoryExists() ? $stage->getStageDirectory() : NULL; + $this->assertValidationResultsEqual($expected_results, $event->getResults(), NULL, $stage_dir); + } + +} + +/** + * Defines a stage specifically for testing purposes. + */ +class TestStage extends StageBase { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager:test'; + + /** + * Implements the magic __sleep() method. + * + * TRICKY: without this, any failed ::assertStatusCheckResults() + * will fail, because PHPUnit will want to serialize all arguments in the call + * stack. + * + * @see https://www.drupal.org/project/auto_updates/issues/3312619#comment-14801308 + */ + public function __sleep(): array { + return []; + } + +} + +/** + * A test version of the disk space validator to bypass system-level functions. + */ +class TestDiskSpaceValidator extends DiskSpaceValidator { + + /** + * Whether the root and vendor directories are on the same logical disk. + * + * @var bool + */ + public $sharedDisk = TRUE; + + /** + * The amount of free space, keyed by path. + * + * @var float[] + */ + public $freeSpace = []; + + /** + * {@inheritdoc} + */ + protected function stat(string $path): array { + return [ + 'dev' => $this->sharedDisk ? 'disk' : uniqid(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function freeSpace(string $path): float { + return $this->freeSpace[$path]; + } + + /** + * {@inheritdoc} + */ + public function temporaryDirectory(): string { + return 'temp'; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php new file mode 100644 index 000000000000..b1863bf7b210 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\Serialization\Yaml; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\PathExcluder\GitExcluder + * @group package_manager + * @internal + */ +class GitExcluderTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $this->installComposerInstallers($project_root); + $active_manipulator = new ActiveFixtureManipulator(); + $active_manipulator + ->addPackage([ + 'name' => 'foo/package_known_to_composer_removed_later', + 'type' => 'drupal-module', + 'version' => '1.0.0', + ], FALSE, TRUE) + ->addPackage([ + 'name' => 'foo/custom_package_known_to_composer', + 'type' => 'drupal-custom-module', + 'version' => '1.0.0', + ], FALSE, TRUE) + ->addPackage([ + 'name' => 'foo/package_with_different_installer_path_known_to_composer', + 'type' => 'drupal-module', + 'version' => '1.0.0', + ], FALSE, TRUE); + // Set the installer path config in the project root where we install the + // package. + $installer_paths['different_installer_path/package_known_to_composer'] = ['foo/package_with_different_installer_path_known_to_composer']; + $this->setInstallerPaths($installer_paths, $project_root); + $active_manipulator->addProjectAtPath("modules/module_not_known_to_composer_in_active") + ->addDotGitFolder($project_root . "/modules/module_not_known_to_composer_in_active") + ->addDotGitFolder($project_root . "/modules/contrib/package_known_to_composer_removed_later") + ->addDotGitFolder($project_root . "/modules/custom/custom_package_known_to_composer") + ->addDotGitFolder($project_root . "/different_installer_path/package_known_to_composer") + ->commitChanges(); + } + + /** + * Tests that Git directories are excluded from stage during PreCreate. + */ + public function testGitDirectoriesExcludedActive(): void { + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $stage = $this->createStage(); + $stage->create(); + /** @var \Drupal\package_manager_bypass\LoggingBeginner $beginner */ + $beginner = $this->container->get(BeginnerInterface::class); + $beginner_args = $beginner->getInvocationArguments(); + $excluded_paths = [ + '.git', + 'modules/module_not_known_to_composer_in_active/.git', + 'modules/example/.git', + ]; + foreach ($excluded_paths as $excluded_path) { + $this->assertContains($excluded_path, $beginner_args[0][2]); + } + $not_excluded_paths = [ + 'modules/contrib/package_known_to_composer_removed_later/.git', + 'modules/custom/custom_package_known_to_composer/.git', + 'different_installer_path/package_known_to_composer/.git', + ]; + foreach ($not_excluded_paths as $not_excluded_path) { + $this->assertNotContains($not_excluded_path, $beginner_args[0][2]); + } + } + + /** + * Tests that Git directories are excluded from active during PreApply. + */ + public function testGitDirectoriesExcludedStage(): void { + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $this->getStageFixtureManipulator() + ->removePackage('foo/package_known_to_composer_removed_later'); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + // Adding a module with .git in stage which is unknown to composer, we + // expect it to not be copied to the active directory. + $path = "$stage_dir/modules/unknown_to_composer_in_stage"; + $fs = new Filesystem(); + $fs->mkdir("$path/.git"); + file_put_contents( + "$path/unknown_to_composer.info.yml", + Yaml::encode([ + 'name' => 'Unknown to composer in stage', + 'type' => 'module', + 'core_version_requirement' => '^9.7 || ^10', + ]) + ); + file_put_contents("$path/.git/excluded.txt", 'Phoenix!'); + + $stage->apply(); + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $committer_args = $committer->getInvocationArguments(); + $excluded_paths = [ + '.git', + 'modules/module_not_known_to_composer_in_active/.git', + 'modules/example/.git', + ]; + // We are missing "modules/unknown_to_composer_in_stage/.git" in excluded + // paths because there is no validation for it as it is assumed about any + // new .git folder in stage directory that either composer is aware of it or + // the developer knows what they are doing. + foreach ($excluded_paths as $excluded_path) { + $this->assertContains($excluded_path, $committer_args[0][2]); + } + $this->assertNotContains('modules/unknown_to_composer_in_stage/.git', $committer_args[0][2]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php new file mode 100644 index 000000000000..6bf3b58ff9bb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\NodeModulesExcluder + * @group package_manager + * @internal + */ +class NodeModulesExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that node_modules directories are excluded from stage operations. + */ + public function testExcludedPaths(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $excluded = [ + "core/node_modules/exclude.txt", + 'modules/example/node_modules/exclude.txt', + ]; + foreach ($excluded as $path) { + mkdir(dirname("$active_dir/$path"), 0777, TRUE); + file_put_contents("$active_dir/$path", "This file should never be staged."); + } + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php new file mode 100644 index 000000000000..cb347e55c12f --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\PathExcluder\SiteConfigurationExcluder; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder + * @group package_manager + * @internal + */ +class SiteConfigurationExcluderTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->getDefinition(SiteConfigurationExcluder::class) + ->setClass(TestSiteConfigurationExcluder::class); + } + + /** + * Tests that certain paths are excluded from stage operations. + */ + public function testExcludedPaths(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $site_path = 'sites/example.com'; + + // Update the event subscribers' dependencies. + $site_configuration_excluder = $this->container->get(SiteConfigurationExcluder::class); + $site_configuration_excluder->sitePath = $site_path; + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + "$site_path/settings.php", + "$site_path/settings.local.php", + "$site_path/services.yml", + // Default site-specific settings files should be excluded. + 'sites/default/settings.php', + 'sites/default/settings.local.php', + 'sites/default/services.yml', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + // A non-excluded file in the default site directory should be staged. + $this->assertFileExists("$stage_dir/sites/default/stage.txt"); + // Regular module files should be staged. + $this->assertFileExists("$stage_dir/modules/example/example.info.yml"); + + // A new file added to the site directory in the stage directory should be + // copied to the active directory. + $file = "$stage_dir/sites/default/new.txt"; + touch($file); + $stage->apply(); + $this->assertFileExists("$active_dir/sites/default/new.txt"); + + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that `sites/default` is made writable in the stage directory. + */ + public function testDefaultSiteDirectoryPermissions(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $live_dir = $project_root . '/sites/default'; + chmod($live_dir, 0555); + $this->assertDirectoryIsNotWritable($live_dir); + // Record the permissions of the directory now, so we can be sure those + // permissions are restored after apply. + $original_permissions = fileperms($live_dir); + $this->assertIsInt($original_permissions); + + $stage = $this->createStage(); + $stage->create(); + // The staged `sites/default` will be made world-writable, because we want + // to ensure the scaffold plugin can copy certain files into there. + $staged_dir = str_replace($project_root, $stage->getStageDirectory(), $live_dir); + $this->assertDirectoryIsWritable($staged_dir); + + $stage->require(['ext-json:*']); + $stage->apply(); + // After applying, the live directory should NOT inherit the staged + // directory's world-writable permissions. + $this->assertSame($original_permissions, fileperms($live_dir)); + } + +} + +/** + * A test version of the site configuration excluder, to expose internals. + */ +class TestSiteConfigurationExcluder extends SiteConfigurationExcluder { + + public string $sitePath; + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php new file mode 100644 index 000000000000..99fa0e2326ac --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\SiteFilesExcluder + * @group package_manager + * @internal + */ +class SiteFilesExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that public and private files are excluded from stage operations. + */ + public function testSiteFilesExcluded(): void { + // The private stream wrapper is only registered if this setting is set. + // @see \Drupal\Core\CoreServiceProvider::register() + $this->setSetting('file_private_path', 'private'); + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. This will also rebuild + // the container, enabling the private stream wrapper. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + // Ensure that we are using directories within the fake site fixture for + // public and private files. + $this->setSetting('file_public_path', "sites/example.com/files"); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + "sites/example.com/files/exclude.txt", + 'private/exclude.txt', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that invalid file settings do not cause errors. + */ + public function testInvalidFileSettings(): void { + $invalid_path = '/path/does/not/exist'; + $this->assertFileDoesNotExist($invalid_path); + $this->setSetting('file_public_path', $invalid_path); + $this->setSetting('file_private_path', $invalid_path); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + $this->assertStatusCheckResults([]); + $this->assertResults([]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php new file mode 100644 index 000000000000..2f8cb42901ea --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\Database\Connection; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder + * @group package_manager + * @internal + */ +class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase { + + /** + * The mocked database connection. + * + * @var \Drupal\Core\Database\Connection|\Prophecy\Prophecy\ObjectProphecy + */ + private Connection|ObjectProphecy $mockDatabase; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $this->mockDatabase = $this->prophesize(Connection::class); + $this->mockDatabase->driver() + ->willReturn('sqlite') + ->shouldBeCalled(); + $container->set('mock_database', $this->mockDatabase->reveal()); + + $container->getDefinition(SqliteDatabaseExcluder::class) + ->setArgument('$database', new Reference('mock_database')); + } + + /** + * Data provider for ::testSqliteDatabaseFilesExcluded(). + * + * @return array[] + * The test cases. + */ + public static function providerSqliteDatabaseFilesExcluded(): array { + return [ + // If the database is at a relative path, it should be excluded relative + // to the web root. + 'relative path in relocated web root' => [ + 'www', + 'db.sqlite', + 'www/db.sqlite', + ], + 'relative path, web root is project root' => [ + '', + 'db.sqlite', + 'db.sqlite', + ], + // If the database is at an absolute path in the project root, it should + // be excluded relative to the project root. + 'absolute path in relocated web root' => [ + 'www', + '<PROJECT_ROOT>/www/db.sqlite', + 'www/db.sqlite', + ], + 'absolute path, web root is project root' => [ + '', + '<PROJECT_ROOT>/db.sqlite', + 'db.sqlite', + ], + // If the database is outside the project root, the excluder doesn't need + // to do anything. + 'absolute path outside of project, relocated web root' => [ + 'www', + '/path/to/database.sqlite', + FALSE, + ], + 'absolute path outside of project, web root is project root' => [ + '', + '/path/to/database.sqlite', + FALSE, + ], + ]; + } + + /** + * Tests that SQLite database files are excluded from stage operations. + * + * @param string $web_root + * The web root that should be returned by the path locator. See + * \Drupal\package_manager\PathLocator::getWebRoot(). + * @param string $db_path + * The path of the SQLite database, as it should be reported by the database + * connection. This can be a relative or absolute path; it does not need to + * actually exist. + * @param string|false $expected_excluded_path + * The path to the database, as it should be given to + * CollectPathsToExcludeEvent. If FALSE, the database is located outside the + * project and therefore is not excluded. + * + * @dataProvider providerSqliteDatabaseFilesExcluded + */ + public function testSqliteDatabaseFilesExcluded(string $web_root, string $db_path, string|false $expected_excluded_path): void { + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + $project_root = $path_locator->getProjectRoot(); + + // Set the mocked web root, keeping everything else as-is. + $path_locator->setPaths( + $project_root, + $path_locator->getVendorDirectory(), + $web_root, + $path_locator->getStagingRoot(), + ); + $db_path = str_replace('<PROJECT_ROOT>', $project_root, $db_path); + $this->mockDatabase->getConnectionOptions() + ->willReturn(['database' => $db_path]) + ->shouldBeCalled(); + + $event = new CollectPathsToExcludeEvent( + $this->createStage(), + $path_locator, + $this->container->get(PathFactoryInterface::class), + ); + $actual_excluded_paths = $this->container->get('event_dispatcher') + ->dispatch($event) + ->getAll(); + + if (is_string($expected_excluded_path)) { + $expected_exclusions = [ + $expected_excluded_path, + $expected_excluded_path . '-shm', + $expected_excluded_path . '-wal', + ]; + $this->assertEmpty(array_diff($expected_exclusions, $actual_excluded_paths)); + } + else { + // The path of the database should not appear anywhere in the list of + // excluded paths. + $this->assertStringNotContainsString($db_path, serialize($actual_excluded_paths)); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php new file mode 100644 index 000000000000..93831d97d56c --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\TestSiteExcluder + * @group package_manager + * @internal + */ +class TestSiteExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that test site directories are excluded from stage operations. + */ + public function testTestSitesExcluded(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + 'sites/simpletest', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php new file mode 100644 index 000000000000..d68da1cba9aa --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php @@ -0,0 +1,246 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\PathExcluder\UnknownPathExcluder + * @group package_manager + * @internal + */ +class UnknownPathExcluderTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function createTestProject(?string $source_dir = NULL): void { + // This class needs the test project to be varied for different test + // methods, so it cannot be called in the setup. + // @see ::createTestProjectForTemplate() + } + + /** + * Creates a test project with or without a nested webroot. + * + * @param bool $use_nested_webroot + * Whether to use a nested webroot. + */ + protected function createTestProjectForTemplate(bool $use_nested_webroot): void { + if (!$use_nested_webroot) { + // We are not using a nested webroot: the parent test project can be used. + parent::createTestProject(); + } + else { + // Create another directory and copy its contents from fake_site fixture. + $fake_site_with_nested_webroot = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'fake_site_with_nested_webroot'; + $fs = new Filesystem(); + if (is_dir($fake_site_with_nested_webroot)) { + $fs->remove($fake_site_with_nested_webroot); + } + $fs->mkdir($fake_site_with_nested_webroot); + $fs->mirror(__DIR__ . '/../../../fixtures/fake_site', $fake_site_with_nested_webroot); + + // Create a webroot directory in our new directory and copy all folders + // and files into it, except for ones that should always be in the + // project root. + $fs->mkdir($fake_site_with_nested_webroot . DIRECTORY_SEPARATOR . 'webroot'); + $paths_in_project_root = glob("$fake_site_with_nested_webroot/*"); + $keep_in_project_root = [ + $fake_site_with_nested_webroot . '/vendor', + $fake_site_with_nested_webroot . '/webroot', + $fake_site_with_nested_webroot . '/composer.json', + $fake_site_with_nested_webroot . '/composer.lock', + $fake_site_with_nested_webroot . '/custom', + ]; + foreach ($paths_in_project_root as $path_in_project_root) { + if (!in_array($path_in_project_root, $keep_in_project_root, TRUE)) { + $fs->rename($path_in_project_root, $fake_site_with_nested_webroot . '/webroot' . str_replace($fake_site_with_nested_webroot, '', $path_in_project_root)); + } + } + parent::createTestProject($fake_site_with_nested_webroot); + + // We need to reset the test paths with our new webroot. + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $path_locator->setPaths( + $path_locator->getProjectRoot(), + $path_locator->getVendorDirectory(), + 'webroot', + $path_locator->getStagingRoot() + ); + } + } + + /** + * Data provider for testUnknownPath(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerTestUnknownPath() { + return [ + 'unknown file where web and project root same' => [ + FALSE, + NULL, + ['unknown_file.txt'], + ], + 'unknown file where web and project root different' => [ + TRUE, + NULL, + ['unknown_file.txt'], + ], + 'unknown hidden file where web and project root same' => [ + FALSE, + NULL, + ['.unknown_file'], + ], + 'unknown hidden file where web and project root different' => [ + TRUE, + NULL, + ['.unknown_file'], + ], + 'unknown directory where web and project root same' => [ + FALSE, + 'unknown_dir', + ['unknown_dir/unknown_dir.README.md', 'unknown_dir/unknown_file.txt'], + ], + 'unknown directory where web and project root different' => [ + TRUE, + 'unknown_dir', + ['unknown_dir/unknown_dir.README.md', 'unknown_dir/unknown_file.txt'], + ], + 'unknown hidden directory where web and project root same' => [ + FALSE, + '.unknown_dir', + ['.unknown_dir/unknown_dir.README.md', '.unknown_dir/unknown_file.txt'], + ], + 'unknown hidden directory where web and project root different' => [ + TRUE, + '.unknown_dir', + ['.unknown_dir/unknown_dir.README.md', '.unknown_dir/unknown_file.txt'], + ], + ]; + } + + /** + * Tests that the unknown files and directories are excluded. + * + * @param bool $use_nested_webroot + * Whether to create test project with a nested webroot. + * @param string|null $unknown_dir + * The path of unknown directory to test or NULL none should be tested. + * @param string[] $unknown_files + * The list of unknown files. + * + * @dataProvider providerTestUnknownPath + */ + public function testUnknownPath(bool $use_nested_webroot, ?string $unknown_dir, array $unknown_files): void { + $this->createTestProjectForTemplate($use_nested_webroot); + + $active_dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + if ($unknown_dir) { + mkdir("$active_dir/$unknown_dir"); + } + foreach ($unknown_files as $unknown_file) { + file_put_contents("$active_dir/$unknown_file", "Unknown File"); + } + + $stage = $this->createStage(); + // Files are only excluded if the web root and project root are different. + // If anything in the project root is excluded, those paths should be + // logged. + if ($use_nested_webroot) { + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('package_manager') + ->addLogger($logger); + + $this->runStatusCheck($stage); + $this->assertTrue($logger->hasRecordThatContains("The following paths in $active_dir aren't recognized as part of your Drupal site, so to be safe, Package Manager is excluding them from all stage operations. If these files are not needed for Composer to work properly in your site, no action is needed. Otherwise, you can disable this behavior by setting the <code>package_manager.settings:include_unknown_files_in_project_root</code> config setting to <code>TRUE</code>.", RfcLogLevel::INFO)); + foreach ($unknown_files as $unknown_file) { + // If $unknown_file is in a subdirectory, only the subdirectory is going + // to be logged as an excluded path. The excluder doesn't recurse into + // subdirectories. + if (str_contains($unknown_file, '/')) { + $unknown_file = dirname($unknown_file); + } + $this->assertTrue($logger->hasRecordThatContains($unknown_file, RfcLogLevel::INFO)); + } + } + + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + foreach ($unknown_files as $path) { + $this->assertFileExists("$active_dir/$path"); + if ($use_nested_webroot) { + // It will not exist in stage as it will be excluded because web and + // project root are different. + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + else { + // If the project root and web root are the same, unknown files will not + // be excluded, so this path should exist in the stage directory. + $this->assertFileExists("$stage_dir/$path"); + } + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($unknown_files as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that the excluder can be disabled by a config flag. + */ + public function testExcluderCanBeDisabled(): void { + $this->createTestProjectForTemplate(TRUE); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + mkdir($project_root . '/unknown'); + touch($project_root . '/unknown/file.txt'); + + $config = $this->config('package_manager.settings'); + $config->set('include_unknown_files_in_project_root', TRUE)->save(); + + $stage = $this->createStage(); + $stage->create(); + $this->assertFileExists($stage->getStageDirectory() . '/unknown/file.txt'); + $stage->destroy(); + + $config->set('include_unknown_files_in_project_root', FALSE)->save(); + $this->assertFileExists($project_root . '/unknown/file.txt'); + $stage->create(); + $this->assertFileDoesNotExist($stage->getStageDirectory() . '/unknown/file.txt'); + } + + public function testPathRepositoriesAreIncluded(): void { + $this->createTestProjectForTemplate(TRUE); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $this->assertDirectoryExists($project_root . '/custom'); + + $stage = $this->createStage(); + $stage->create(); + $this->assertDirectoryExists($stage->getStageDirectory() . '/custom'); + $stage->require(['ext-json:*']); + $stage->apply(); + $this->assertDirectoryExists($project_root . '/custom'); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php new file mode 100644 index 000000000000..7e2c53605095 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\VendorHardeningExcluder + * @group package_manager + * @internal + */ +class VendorHardeningExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that vendor hardening files are excluded from stage operations. + */ + public function testVendorHardeningFilesExcluded(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = ['vendor/.htaccess']; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php new file mode 100644 index 000000000000..d8682d60fae0 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\PendingUpdatesValidator + * @group package_manager + * @internal + */ +class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system']; + + /** + * Tests that no error is raised if there are no pending updates. + */ + public function testNoPendingUpdates(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending schema updates. + * + * @depends testNoPendingUpdates + */ + public function testPendingUpdateHook(): void { + // Set the installed schema version of Package Manager to its default value + // and import an empty update hook which is numbered much higher than will + // ever exist in the real world. + $this->container->get('keyvalue') + ->get('system.schema') + ->set('package_manager', \Drupal::CORE_MINIMUM_SCHEMA_VERSION); + + require_once __DIR__ . '/../../fixtures/db_update.php'; + + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending post-updates. + */ + public function testPendingPostUpdate(): void { + // Make an additional post-update function available; the update registry + // will think it's pending. + require_once __DIR__ . '/../../fixtures/post_update.php'; + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that pending updates stop an operation from being applied. + */ + public function testPendingUpdateAfterStaged(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + // Make an additional post-update function available; the update registry + // will think it's pending. + require_once __DIR__ . '/../../fixtures/post_update.php'; + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + try { + $stage->apply(); + $this->fail('Able to apply update even though there is pending update.'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException([$result], $exception); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php new file mode 100644 index 000000000000..727c9ba32926 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\PhpExtensionsValidator + * @group package_manager + * @internal + */ +class PhpExtensionsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for ::testPhpExtensionsValidation(). + * + * @return array[] + * The test cases. + */ + public static function providerPhpExtensionsValidation(): array { + $openssl_error = ValidationResult::createError([ + t('The OpenSSL extension is not enabled, which is a security risk. See <a href="https://www.php.net/manual/en/openssl.installation.php">the PHP documentation</a> for information on how to enable this extension.'), + ]); + $xdebug_warning = ValidationResult::createWarning([ + t('Xdebug is enabled, which may have a negative performance impact on Package Manager and any modules that use it.'), + ]); + return [ + 'xdebug enabled, openssl installed' => [ + ['xdebug', 'openssl'], + [$xdebug_warning], + [], + ], + 'xdebug enabled, openssl not installed' => [ + ['xdebug'], + [$xdebug_warning, $openssl_error], + [$openssl_error], + ], + 'xdebug disabled, openssl installed' => [ + ['openssl'], + [], + [], + ], + 'xdebug disabled, openssl not installed' => [ + [], + [$openssl_error], + [$openssl_error], + ], + ]; + } + + /** + * Tests that PHP extensions' status are checked by Package Manager. + * + * @param string[] $loaded_extensions + * The names of the PHP extensions that the validator should think are + * loaded. + * @param \Drupal\package_manager\ValidationResult[] $expected_status_check_results + * The expected validation results during the status check event. + * @param \Drupal\package_manager\ValidationResult[] $expected_life_cycle_results + * The expected validation results during pre-create and pre-apply event. + * + * @dataProvider providerPhpExtensionsValidation + */ + public function testPhpExtensionsValidation(array $loaded_extensions, array $expected_status_check_results, array $expected_life_cycle_results): void { + $state = $this->container->get('state'); + // @see \Drupal\package_manager\Validator\PhpExtensionsValidator::isExtensionLoaded() + $state->set('package_manager_loaded_php_extensions', $loaded_extensions); + + $this->assertStatusCheckResults($expected_status_check_results); + $this->assertResults($expected_life_cycle_results, PreCreateEvent::class); + // To test pre-apply delete the loaded extensions in state which will allow + // the pre-create event to run without a validation error. + $state->delete('package_manager_loaded_php_extensions'); + // On post-create set the loaded extensions in state so that the pre-apply + // event will have the expected validation error. + $this->addEventTestListener(function () use ($state, $loaded_extensions) { + $state->set('package_manager_loaded_php_extensions', $loaded_extensions); + }, PostCreateEvent::class); + $this->assertResults($expected_life_cycle_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php new file mode 100644 index 000000000000..ea0eb41f39a1 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php @@ -0,0 +1,233 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\LockFileValidator; +use Drupal\package_manager\Validator\PhpTufValidator; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\PhpTufValidator + * @group package_manager + * @internal + */ +class PhpTufValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // PHP-TUF must be enabled for this test to run. + $this->setSetting('package_manager_bypass_tuf', FALSE); + + (new ActiveFixtureManipulator()) + ->addConfig([ + 'repositories.drupal' => [ + 'type' => 'composer', + 'url' => 'https://packages.drupal.org/8', + 'tuf' => TRUE, + ], + 'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => TRUE, + ]) + ->addPackage([ + 'name' => PhpTufValidator::PLUGIN_NAME, + 'type' => 'composer-plugin', + 'require' => [ + 'composer-plugin-api' => '*', + ], + 'extra' => [ + 'class' => 'PhpTufComposerPlugin', + ], + ]) + ->commitChanges(); + } + + /** + * Tests that there are no errors if the plugin is set up correctly. + */ + public function testPluginInstalledAndConfiguredProperly(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([]); + } + + /** + * Tests there is an error if the plugin is not installed in the project root. + */ + public function testPluginNotInstalledInProjectRoot(): void { + (new ActiveFixtureManipulator()) + ->removePackage(PhpTufValidator::PLUGIN_NAME) + ->commitChanges(); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests removing the plugin from the stage on pre-require. + */ + public function testPluginRemovedFromStagePreRequire(): void { + $this->getStageFixtureManipulator() + ->removePackage(PhpTufValidator::PLUGIN_NAME); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertResults([$result], PreRequireEvent::class); + } + + /** + * Tests removing the plugin from the stage before applying it. + */ + public function testPluginRemovedFromStagePreApply(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + (new FixtureManipulator()) + ->removePackage(PhpTufValidator::PLUGIN_NAME) + ->commitChanges($stage->getStageDirectory()); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + try { + $stage->apply(); + $this->fail('Expected an exception but none was thrown.'); + } + catch (StageEventException $e) { + $this->assertInstanceOf(PreApplyEvent::class, $e->event); + $this->assertValidationResultsEqual([$result], $e->event->getResults()); + } + } + + /** + * Data provider for testing invalid plugin configuration. + * + * @return array[] + * The test cases. + */ + public static function providerInvalidConfiguration(): array { + return [ + 'plugin specifically disallowed' => [ + [ + 'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => FALSE, + ], + [ + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ], + ], + 'all plugins disallowed' => [ + [ + 'allow-plugins' => FALSE, + ], + [ + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ], + ], + 'packages.drupal.org not using TUF' => [ + [ + 'repositories.drupal' => [ + 'type' => 'composer', + 'url' => 'https://packages.drupal.org/8', + ], + ], + [ + t('TUF is not enabled for the <code>https://packages.drupal.org/8</code> repository.'), + ], + ], + ]; + } + + /** + * Data provider for testing invalid plugin configuration in the stage. + * + * @return \Generator + * The test cases. + */ + public static function providerInvalidConfigurationInStage(): \Generator { + foreach (static::providerInvalidConfiguration() as $name => $arguments) { + $arguments[] = PreRequireEvent::class; + yield "$name on pre-require" => $arguments; + + array_splice($arguments, -1, NULL, PreApplyEvent::class); + yield "$name on pre-apply" => $arguments; + } + } + + /** + * Tests errors caused by invalid plugin configuration in the project root. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected error messages. + * + * @dataProvider providerInvalidConfiguration + */ + public function testInvalidConfigurationInProjectRoot(array $config, array $expected_messages): void { + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + + $result = ValidationResult::createError($expected_messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests errors caused by invalid plugin configuration in the stage directory. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected error messages. + * @param string $event_class + * The event before which the plugin's configuration should be changed. + * + * @dataProvider providerInvalidConfigurationInStage + */ + public function testInvalidConfigurationInStage(array $config, array $expected_messages, string $event_class): void { + $listener = function (PreRequireEvent|PreApplyEvent $event) use ($config): void { + (new FixtureManipulator()) + ->addConfig($config) + ->commitChanges($event->stage->getStageDirectory()); + }; + $this->addEventTestListener($listener, $event_class); + + // LockFileValidator will complain because we have not added, removed, or + // updated any packages in the stage. In this very specific situation, it's + // okay to disable that validator to remove the interference. + if ($event_class === PreApplyEvent::class) { + $lock_file_validator = $this->container->get(LockFileValidator::class); + $this->container->get('event_dispatcher') + ->removeSubscriber($lock_file_validator); + } + + $result = ValidationResult::createError($expected_messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertResults([$result], $event_class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php new file mode 100644 index 000000000000..db27b06efa87 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\ProcessFactory; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; + +/** + * @coversDefaultClass \Drupal\package_manager\ProcessFactory + * @group auto_updates + * @internal + */ +class ProcessFactoryTest extends PackageManagerKernelTestBase { + + /** + * Tests that the process factory prepends the PHP directory to PATH. + */ + public function testPhpDirectoryPrependedToPath(): void { + $factory = $this->container->get(ProcessFactoryInterface::class); + $this->assertInstanceOf(ProcessFactory::class, $factory); + + // Ensure that the directory of the PHP interpreter can be found. + $reflector = new \ReflectionObject($factory); + $method = $reflector->getMethod('getPhpDirectory'); + $php_dir = $method->invoke(NULL); + $this->assertNotEmpty($php_dir); + + // The process factory should always put the PHP interpreter's directory + // at the beginning of the PATH environment variable. + $env = $factory->create(['whoami'])->getEnv(); + $this->assertStringStartsWith("$php_dir:", $env['PATH']); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php new file mode 100644 index 000000000000..16bb603b1894 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php @@ -0,0 +1,303 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\ProjectInfo; + +/** + * @coversDefaultClass \Drupal\package_manager\ProjectInfo + * @group auto_updates + * @internal + */ +class ProjectInfoTest extends PackageManagerKernelTestBase { + + /** + * @covers ::getInstallableReleases + * + * @param string $fixture + * The fixture file name. + * @param string $installed_version + * The installed version core version to set. + * @param string[] $expected_versions + * The expected versions. + * + * @dataProvider providerGetInstallableReleases + */ + public function testGetInstallableReleases(string $fixture, string $installed_version, array $expected_versions): void { + [$project] = explode('.', $fixture); + $fixtures_directory = __DIR__ . '/../../fixtures/release-history/'; + if ($project === 'drupal') { + $this->setCoreVersion($installed_version); + } + else { + // Update the version and the project of the project. + $this->enableModules(['package_manager_test_update']); + $extension_info_update = [ + 'version' => $installed_version, + 'project' => 'package_manager_test_update', + ]; + // @todo Replace with use of the trait from the Update module in https://drupal.org/i/3348234. + $this->config('update_test.settings') + ->set("system_info.$project", $extension_info_update) + ->save(); + // The Update module will always request Drupal core's update XML. + $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml'; + } + $metadata_fixtures[$project] = "$fixtures_directory$fixture"; + $this->setReleaseMetadata($metadata_fixtures); + $project_info = new ProjectInfo($project); + $actual_releases = $project_info->getInstallableReleases(); + // Assert that we returned the correct releases in the expected order. + $this->assertSame($expected_versions, array_keys($actual_releases)); + // Assert that we version keys match the actual releases. + foreach ($actual_releases as $version => $release) { + $this->assertSame($version, $release->getVersion()); + } + } + + /** + * Data provider for testGetInstallableReleases(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerGetInstallableReleases(): array { + return [ + 'core, no updates' => [ + 'drupal.9.8.2.xml', + '9.8.2', + [], + ], + 'core, on supported branch, pre-release in next minor' => [ + 'drupal.9.8.0-alpha1.xml', + '9.7.1', + ['9.8.0-alpha1'], + ], + 'core, on unsupported branch, updates in multiple supported branches' => [ + 'drupal.9.8.2.xml', + '9.6.0-alpha1', + ['9.8.2', '9.8.1', '9.8.0', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'], + ], + // A test case with an unpublished release, 9.8.0, and unsupported + // release, 9.8.1, both of these releases should not be returned. + 'core, filter out unsupported and unpublished releases' => [ + 'drupal.9.8.2-unsupported_unpublished.xml', + '9.6.0-alpha1', + ['9.8.2', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'], + ], + 'core, supported branches before and after installed release' => [ + 'drupal.9.8.2.xml', + '9.8.0-alpha1', + ['9.8.2', '9.8.1', '9.8.0'], + ], + 'core, one insecure release filtered out' => [ + 'drupal.9.8.1-security.xml', + '9.8.0-alpha1', + ['9.8.1'], + ], + 'core, skip insecure releases and return secure releases' => [ + 'drupal.9.8.2-older-sec-release.xml', + '9.7.0-alpha1', + ['9.8.2', '9.8.1', '9.8.1-beta1', '9.8.0-alpha1', '9.7.1'], + ], + 'contrib, semver and legacy' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.0-alpha1', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0'], + ], + 'contrib, semver and legacy, some lower' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.1', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2'], + ], + 'contrib, semver and legacy, on semantic dev' => [ + 'package_manager_test_update.7.0.1.xml', + '7.0.x-dev', + ['7.0.1', '7.0.0', '7.0.0-alpha1'], + ], + 'contrib, semver and legacy, on legacy dev' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.x-dev', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0', '8.x-6.0-alpha1'], + ], + ]; + } + + /** + * Tests a project that is not in the codebase. + */ + public function testNewProject(): void { + $fixtures_directory = __DIR__ . '/../../fixtures/release-history/'; + $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml'; + $metadata_fixtures['package_manager_test_update'] = $fixtures_directory . 'package_manager_test_update.7.0.1.xml'; + $this->setReleaseMetadata($metadata_fixtures); + $available = update_get_available(TRUE); + $this->assertSame(['drupal'], array_keys($available)); + $this->setReleaseMetadata($metadata_fixtures); + $state = $this->container->get('state'); + // Set the state that the update module uses to store last checked time + // ensure our calls do not affect it. + $state->set('update.last_check', 123); + $project_info = new ProjectInfo('package_manager_test_update'); + $project_data = $project_info->getProjectInfo(); + // Ensure the project information is correct. + $this->assertSame('Package Manager Test Update', $project_data['title']); + $all_releases = [ + '7.0.1', + '7.0.0', + '7.0.0-alpha1', + '8.x-6.2', + '8.x-6.1', + '8.x-6.0', + '8.x-6.0-alpha1', + '7.0.x-dev', + '8.x-6.x-dev', + '8.x-5.x', + ]; + $uninstallable_releases = ['7.0.x-dev', '8.x-6.x-dev', '8.x-5.x']; + $installable_releases = array_values(array_diff($all_releases, $uninstallable_releases)); + $this->assertSame( + $all_releases, + array_keys($project_data['releases']) + ); + $this->assertSame( + $installable_releases, + array_keys($project_info->getInstallableReleases()) + ); + $this->assertNull($project_info->getInstalledVersion()); + // Ensure we have not changed the state the update module uses to store + // the last checked time. + $this->assertSame(123, $state->get('update.last_check')); + + $this->assertTrue($this->failureLogger->hasRecordThatContains('Invalid project format: Array', (string) RfcLogLevel::ERROR)); + $this->assertTrue($this->failureLogger->hasRecordThatContains('[name] => Package Manager Test Update 8.x-5.x', (string) RfcLogLevel::ERROR)); + // Prevent the logged errors from causing failures during tear-down. + $this->failureLogger->reset(); + } + + /** + * Tests a project with a status other than "published". + * + * @covers ::getInstallableReleases + */ + public function testNotPublishedProject(): void { + $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2_unknown_status.xml']); + $project_info = new ProjectInfo('drupal'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("The project 'drupal' can not be updated because its status is any status besides published"); + $project_info->getInstallableReleases(); + } + + /** + * Data provider for ::testInstalledVersionSafe(). + * + * @return array[] + * The test cases. + */ + public static function providerInstalledVersionSafe(): array { + $dir = __DIR__ . '/../../fixtures/release-history'; + + return [ + 'safe version' => [ + '9.8.0', + $dir . '/drupal.9.8.2.xml', + TRUE, + ], + 'unpublished version' => [ + '9.8.0', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported branch' => [ + '9.6.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported version' => [ + '9.8.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'insecure version' => [ + '9.8.0', + $dir . '/drupal.9.8.1-security.xml', + FALSE, + ], + ]; + } + + /** + * Tests checking if the currently installed version of a project is safe. + * + * @param string $installed_version + * The currently installed version of the project. + * @param string $release_xml + * The path of the release metadata. + * @param bool $expected_to_be_safe + * Whether the installed version of the project is expected to be found + * safe. + * + * @covers ::isInstalledVersionSafe + * + * @dataProvider providerInstalledVersionSafe + */ + public function testInstalledVersionSafe(string $installed_version, string $release_xml, bool $expected_to_be_safe): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_xml]); + + $project_info = new ProjectInfo('drupal'); + $this->assertSame($expected_to_be_safe, $project_info->isInstalledVersionSafe()); + } + + /** + * Data provider for testGetSupportedBranches(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerGetSupportedBranches(): array { + $dir = __DIR__ . '/../../fixtures/release-history/'; + + return [ + 'xml with supported branches' => [ + $dir . 'drupal.10.0.0.xml', + [ + '9.5.', + '9.6.', + '9.7.', + '10.0.', + ], + ], + 'xml with supported branches not set' => [ + $dir . 'drupal.9.8.1-supported_branches_not_set.xml', + [], + ], + 'xml with empty supported branches' => [ + $dir . 'drupal.9.8.1-empty_supported_branches.xml', + [ + '', + ], + ], + ]; + } + + /** + * @covers ::getSupportedBranches + * + * @param string $release_xml + * The path of the release metadata. + * @param string[] $expected_supported_branches + * The expected supported branches. + * + * @dataProvider providerGetSupportedBranches + */ + public function testGetSupportedBranches(string $release_xml, array $expected_supported_branches): void { + $this->setReleaseMetadata(['drupal' => $release_xml]); + $project_info = new ProjectInfo('drupal'); + $this->assertSame($expected_supported_branches, $project_info->getSupportedBranches()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php new file mode 100644 index 000000000000..e066100e1882 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\RsyncValidator; +use PhpTuf\ComposerStager\API\Exception\LogicException; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @covers \Drupal\package_manager\Validator\RsyncValidator + * @group package_manager + * @internal + */ +class RsyncValidatorTest extends PackageManagerKernelTestBase { + + /** + * The mocked executable finder. + * + * @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface + */ + private $executableFinder; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // Set up a mocked executable finder which will always be re-injected into + // the validator when the container is rebuilt. + $this->executableFinder = $this->prophesize(ExecutableFinderInterface::class); + $this->executableFinder->find('rsync')->willReturn('/path/to/rsync'); + + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->set('mock_executable_finder', $this->executableFinder->reveal()); + + $container->getDefinition(RsyncValidator::class) + ->setArgument('$executableFinder', new Reference('mock_executable_finder')); + } + + /** + * Tests that the stage cannot be created if rsync is selected, but not found. + */ + public function testPreCreateFailsIfRsyncNotFound(): void { + /** @var \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $translatable_factory */ + $translatable_factory = $this->container->get(TranslatableFactoryInterface::class); + $message = $translatable_factory->createTranslatableMessage('Nope!'); + $this->executableFinder->find('rsync')->willThrow(new LogicException($message)); + + $result = ValidationResult::createError([ + t('<code>rsync</code> is not available.'), + ]); + $this->assertResults([$result], PreCreateEvent::class); + + $this->enableModules(['help']); + + $result = ValidationResult::createError([ + t('<code>rsync</code> is not available. See the <a href="/admin/help/package_manager#package-manager-faq-rsync">Package Manager help</a> for more information on how to resolve this.'), + ]); + $this->assertResults([$result], PreCreateEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ServicesTest.php b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php new file mode 100644 index 000000000000..a1db5a517c15 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\ExecutableFinder; +use Drupal\package_manager\LoggingBeginner; +use Drupal\package_manager\LoggingCommitter; +use Drupal\package_manager\LoggingStager; +use Drupal\package_manager\ProcessFactory; +use Drupal\package_manager\TranslatableStringFactory; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; + +/** + * Tests that Package Manager services are wired correctly. + * + * @group package_manager + * @internal + */ +class ServicesTest extends KernelTestBase { + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager', 'update']; + + /** + * Tests that Package Manager's public services can be instantiated. + */ + public function testPackageManagerServices(): void { + // Ensure that any overridden Composer Stager services were overridden + // correctly. + $overrides = [ + ExecutableFinderInterface::class => ExecutableFinder::class, + ProcessFactoryInterface::class => ProcessFactory::class, + TranslatableFactoryInterface::class => TranslatableStringFactory::class, + BeginnerInterface::class => LoggingBeginner::class, + StagerInterface::class => LoggingStager::class, + CommitterInterface::class => LoggingCommitter::class, + ]; + foreach ($overrides as $interface => $expected_class) { + $this->assertInstanceOf($expected_class, $this->container->get($interface)); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php new file mode 100644 index 000000000000..850d8ce03d9d --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\SettingsValidator + * @group package_manager + * @internal + */ +class SettingsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testSettingsValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerSettingsValidation(): array { + $result = ValidationResult::createError([t('The <code>update_fetch_with_http_fallback</code> setting must be disabled.')]); + + return [ + 'HTTP fallback enabled' => [TRUE, [$result]], + 'HTTP fallback disabled' => [FALSE, []], + ]; + } + + /** + * Tests settings validation before starting an update. + * + * @param bool $setting + * The value of the update_fetch_with_http_fallback setting. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerSettingsValidation + */ + public function testSettingsValidation(bool $setting, array $expected_results): void { + $this->setSetting('update_fetch_with_http_fallback', $setting); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests settings validation during pre-apply. + * + * @param bool $setting + * The value of the update_fetch_with_http_fallback setting. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerSettingsValidation + */ + public function testSettingsValidationDuringPreApply(bool $setting, array $expected_results): void { + $this->addEventTestListener(function () use ($setting): void { + $this->setSetting('update_fetch_with_http_fallback', $setting); + }); + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php b/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php new file mode 100644 index 000000000000..7fca967a9325 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php @@ -0,0 +1,831 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Datetime\Time; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ModuleUninstallValidatorException; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\ApplyFailedException; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\Validator\WritableFileSystemValidator; +use Drupal\package_manager_bypass\LoggingBeginner; +use Drupal\package_manager_bypass\LoggingCommitter; +use Drupal\package_manager_bypass\NoOpStager; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Precondition\Service\PreconditionInterface; +use Psr\Log\LogLevel; +use ColinODell\PsrTestLogger\TestLogger; + +/** + * @coversDefaultClass \Drupal\package_manager\StageBase + * @covers \Drupal\package_manager\PackageManagerUninstallValidator + * @group package_manager + * @internal + */ +class StageBaseTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager_test_validation']; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->getDefinition('datetime.time') + ->setClass(TestTime::class); + + // Since this test adds arbitrary event listeners that aren't services, we + // need to ensure they will persist even if the container is rebuilt when + // staged changes are applied. + $container->getDefinition('event_dispatcher')->addTag('persist'); + } + + /** + * Data provider for testLoggedOnError(). + * + * @return string[][] + * The test cases. + */ + public static function providerLoggedOnError(): array { + return [ + [PreCreateEvent::class], + [PostCreateEvent::class], + [PreRequireEvent::class], + [PostRequireEvent::class], + [PreApplyEvent::class], + [PostApplyEvent::class], + ]; + } + + /** + * @covers \Drupal\package_manager\StageBase::dispatch + * + * @dataProvider providerLoggedOnError + * + * @param string $event_class + * The event class to throw an exception on. + */ + public function testLoggedOnError(string $event_class): void { + $exception = new \Exception("This should be logged!"); + TestSubscriber::setException($exception, $event_class); + + $stage = $this->createStage(); + $logger = new TestLogger(); + $stage->setLogger($logger); + + try { + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $this->fail('Expected an exception to be thrown, but none was.'); + } + catch (StageEventException $e) { + $this->assertInstanceOf($event_class, $e->event); + + $predicate = function (array $record) use ($e): bool { + $context = $record['context']; + return $context['@message'] === $e->getMessage() && str_contains($context['@backtrace_string'], 'testLoggedOnError'); + }; + $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::ERROR)); + } + } + + /** + * @covers ::getMetadata + * @covers ::setMetadata + */ + public function testMetadata(): void { + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->setMetadata('new_key', 'value'); + $this->assertSame('value', $stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure that metadata associated with the previous stage was deleted. + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure metadata cannot be accessed or set unless the stage has been + // claimed. + $stage = $this->createStage(); + try { + $stage->getMetadata('new_key'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + + try { + $stage->setMetadata('new_key', 'value'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + } + + /** + * @covers ::getStageDirectory + */ + public function testGetStageDirectory(): void { + // In this test, we're working with paths that (probably) don't exist in + // the file system at all, so we don't want to validate that the file system + // is writable when creating stages. + $validator = $this->container->get(WritableFileSystemValidator::class); + $this->container->get('event_dispatcher')->removeSubscriber($validator); + + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $stage = $this->createStage(); + $id = $stage->create(); + $stage_dir = $stage->getStageDirectory(); + $this->assertStringStartsWith($path_locator->getStagingRoot() . '/', $stage_dir); + $this->assertStringEndsWith("/$id", $stage_dir); + // If the stage root directory is changed, the existing stage shouldn't be + // affected... + $active_dir = $path_locator->getProjectRoot(); + $new_staging_root = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'junk'; + if (!is_dir($new_staging_root)) { + mkdir($new_staging_root); + } + $path_locator->setPaths($active_dir, "$active_dir/vendor", '', $new_staging_root); + $this->assertSame($stage_dir, $stage->getStageDirectory()); + $stage->destroy(); + // ...but a new stage should be. + $stage = $this->createStage(); + $another_id = $stage->create(); + $this->assertNotSame($id, $another_id); + $stage_dir = $stage->getStageDirectory(); + $this->assertStringStartsWith(realpath($new_staging_root), $stage_dir); + $this->assertStringEndsWith("/$another_id", $stage_dir); + } + + /** + * @covers ::getStageDirectory + */ + public function testUncreatedGetStageDirectory(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Drupal\package_manager\StageBase::getStageDirectory() cannot be called because the stage has not been created or claimed.'); + $this->createStage()->getStageDirectory(); + } + + /** + * Data provider for testDestroyDuringApply(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerDestroyDuringApply(): array { + $error_message_while_being_applied = 'Cannot destroy the stage directory while it is being applied to the active directory.'; + return [ + 'force destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + TRUE, + 1, + $error_message_while_being_applied, + ], + 'destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + FALSE, + 1, + $error_message_while_being_applied, + ], + 'force destroy on pre-apply, stale' => [ + PreApplyEvent::class, + TRUE, + 7200, + 'Stage directory does not exist', + ], + 'destroy on pre-apply, stale' => [ + PreApplyEvent::class, + FALSE, + 7200, + 'Stage directory does not exist', + ], + 'force destroy on post-apply, fresh' => [ + PostApplyEvent::class, + TRUE, + 1, + $error_message_while_being_applied, + ], + 'destroy on post-apply, fresh' => [ + PostApplyEvent::class, + FALSE, + 1, + $error_message_while_being_applied, + ], + 'force destroy on post-apply, stale' => [ + PostApplyEvent::class, + TRUE, + 7200, + NULL, + ], + 'destroy on post-apply, stale' => [ + PostApplyEvent::class, + FALSE, + 7200, + NULL, + ], + ]; + } + + /** + * Tests destroying a stage while applying it. + * + * @param string $event_class + * The event class for which to attempt to destroy the stage. + * @param bool $force + * Whether the stage should be force destroyed. + * @param int $time_offset + * How many simulated seconds should have elapsed between the PreApplyEvent + * being dispatched and the attempt to destroy the stage. + * @param string|null $expected_exception_message + * The expected exception message string if an exception is expected, or + * NULL if no exception message was expected. + * + * @dataProvider providerDestroyDuringApply + */ + public function testDestroyDuringApply(string $event_class, bool $force, int $time_offset, ?string $expected_exception_message): void { + $listener = function (StageEvent $event) use ($force, $time_offset): void { + // Simulate that a certain amount of time has passed since we started + // applying staged changes. After a point, it should be possible to + // destroy the stage even if it hasn't finished. + TestTime::$offset = $time_offset; + + // No real-life event subscriber should try to destroy the stage while + // handling another event. The only reason we're doing it here is to + // simulate an attempt to destroy the stage while it's being applied, for + // testing purposes. + $event->stage->destroy($force); + LoggingCommitter::setException( + PreconditionException::class, + $this->createMock(PreconditionInterface::class), + $this->createComposeStagerMessage('Stage directory does not exist'), + ); + }; + $this->addEventTestListener($listener, $event_class, 0); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + if ($expected_exception_message) { + $this->expectException(StageException::class); + $this->expectExceptionMessage($expected_exception_message); + } + $stage->apply(); + + // If the stage was successfully destroyed by the event handler (i.e., the + // stage has been applying for too long and is therefore considered stale), + // the postApply() method should fail because the stage is not claimed. + if ($stage->isAvailable()) { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Stage must be claimed before performing any operations on it.'); + } + $stage->postApply(); + } + + /** + * Test uninstalling any module while the staged changes are being applied. + */ + public function testUninstallModuleDuringApply(): void { + $listener = function (PreApplyEvent $event): void { + $this->assertTrue($event->stage->isApplying()); + + // Trying to uninstall any module while the stage is being applied should + // result in a module uninstall validation error. + try { + $this->container->get('module_installer') + ->uninstall(['package_manager_bypass']); + $this->fail('Expected an exception to be thrown while uninstalling a module.'); + } + catch (ModuleUninstallValidatorException $e) { + $this->assertStringContainsString('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.', $e->getMessage()); + } + }; + $this->addEventTestListener($listener); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + } + + /** + * Tests that Composer Stager is invoked with a long timeout. + */ + public function testTimeouts(): void { + $stage = $this->createStage(); + $stage->create(420); + $stage->require(['ext-json:*']); + $stage->apply(); + + $timeouts = [ + // The beginner was given an explicit timeout. + BeginnerInterface::class => 420, + // The stager should be called with a timeout of 300 seconds, which is + // longer than Composer Stager's default timeout of 120 seconds. + StagerInterface::class => 300, + // The committer should have been called with an even longer timeout, + // since it's the most failure-sensitive operation. + CommitterInterface::class => 600, + ]; + foreach ($timeouts as $service_id => $expected_timeout) { + $invocations = $this->container->get($service_id)->getInvocationArguments(); + + // The services should have been called with the expected timeouts. + $expected_count = 1; + if ($service_id === StagerInterface::class) { + // Stage::require() calls Stager::stage() twice, once to change the + // version constraints in composer.json, and again to actually update + // the installed dependencies. + $expected_count = 2; + } + $this->assertCount($expected_count, $invocations); + $this->assertSame($expected_timeout, end($invocations[0])); + } + } + + /** + * Data provider for testCommitException(). + * + * @return \string[][] + * The test cases. + */ + public static function providerCommitException(): array { + return [ + 'RuntimeException to ApplyFailedException' => [ + 'RuntimeException', + ApplyFailedException::class, + ], + 'InvalidArgumentException' => [ + InvalidArgumentException::class, + StageException::class, + ], + 'PreconditionException' => [ + PreconditionException::class, + StageException::class, + ], + 'Exception' => [ + 'Exception', + ApplyFailedException::class, + ], + ]; + } + + /** + * Tests exception handling during calls to Composer Stager commit. + * + * @param string $thrown_class + * The throwable class that should be thrown by Composer Stager. + * @param string $expected_class + * The expected exception class, if different from $thrown_class. + * + * @dataProvider providerCommitException + */ + public function testCommitException(string $thrown_class, string $expected_class): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + + $throwable_arguments = [ + 'A very bad thing happened', + 123, + ]; + // Composer Stager's exception messages are usually translatable, so they + // need to be wrapped by a TranslatableMessage object. + if (is_subclass_of($thrown_class, ExceptionInterface::class)) { + $throwable_arguments[0] = $this->createComposeStagerMessage($throwable_arguments[0]); + } + // PreconditionException requires a preconditions object. + if ($thrown_class === PreconditionException::class) { + array_unshift($throwable_arguments, $this->createMock(PreconditionInterface::class)); + } + LoggingCommitter::setException($thrown_class, ...$throwable_arguments); + + try { + $stage->apply(); + $this->fail('Expected an exception.'); + } + catch (\Throwable $exception) { + $this->assertInstanceOf($expected_class, $exception); + $this->assertSame(123, $exception->getCode()); + + // This needs to be done because we always use the message from + // \Drupal\package_manager\Stage::getFailureMarkerMessage() when throwing + // ApplyFailedException. + if ($expected_class == ApplyFailedException::class) { + $this->assertMatchesRegularExpression("/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by $thrown_class, with this message: A very bad thing happened\nBacktrace:\n#0 .*/", $exception->getMessage()); + } + else { + $this->assertSame('A very bad thing happened', $exception->getMessage()); + } + + $failure_marker = $this->container->get(FailureMarker::class); + if ($exception instanceof ApplyFailedException) { + $this->assertFileExists($failure_marker->getPath()); + $this->assertFalse($stage->isApplying()); + } + else { + $failure_marker->assertNotExists(); + } + } + } + + /** + * Tests that if a stage fails to apply, another stage cannot be created. + */ + public function testFailureMarkerPreventsCreate(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + // Make the committer throw an exception, which should cause the failure + // marker to be present. + $thrown_message = 'Thrown by the committer.'; + LoggingCommitter::setException(\Exception::class, $thrown_message); + try { + $stage->apply(); + $this->fail('Expected an exception.'); + } + catch (ApplyFailedException $e) { + $this->assertStringContainsString($thrown_message, $e->getMessage()); + $this->assertFalse($stage->isApplying()); + } + $stage->destroy(); + + // Even through the previous stage was destroyed, we cannot create a new one + // because the failure marker is still there. + $stage = $this->createStage(); + try { + $stage->create(); + $this->fail('Expected an exception.'); + } + catch (StageFailureMarkerException $e) { + $this->assertMatchesRegularExpression('/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: ' . $thrown_message . "\nBacktrace:\n#0 .*/", $e->getMessage()); + $this->assertFalse($stage->isApplying()); + } + + // If the failure marker is cleared, we should be able to create the stage + // without issue. + $this->container->get(FailureMarker::class)->clear(); + $stage->create(); + } + + /** + * Tests that the failure marker file doesn't exist if apply succeeds. + * + * @see ::testCommitException + */ + public function testNoFailureFileOnSuccess(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + + $this->container->get(FailureMarker::class) + ->assertNotExists(); + } + + /** + * Data provider for testStoreDestroyInfo(). + * + * @return \string[][] + * The test cases. + */ + public static function providerStoreDestroyInfo(): array { + return [ + 'Changes applied' => [ + FALSE, + TRUE, + NULL, + 'This operation has already been applied.', + ], + 'Changes not applied and forced' => [ + TRUE, + FALSE, + NULL, + 'This operation was canceled by another user.', + ], + 'Changes not applied and not forced' => [ + FALSE, + FALSE, + NULL, + 'This operation was already canceled.', + ], + 'Changes applied, with a custom exception message.' => [ + FALSE, + TRUE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + 'Changes not applied and forced, with a custom exception message.' => [ + TRUE, + FALSE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + 'Changes not applied and not forced, with a custom exception message.' => [ + FALSE, + FALSE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + ]; + } + + /** + * Tests exceptions thrown because of previously destroyed stage. + * + * @param bool $force + * Whether the stage was forcefully destroyed. + * @param bool $changes_applied + * Whether the changes are applied. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message + * A message about why the stage was destroyed or null. + * @param string $expected_exception_message + * The expected exception message string. + * + * @dataProvider providerStoreDestroyInfo + */ + public function testStoreDestroyInfo(bool $force, bool $changes_applied, ?TranslatableMarkup $message, string $expected_exception_message): void { + $stage = $this->createStage(); + $stage_id = $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $tempstore = $this->container->get('tempstore.shared'); + // Simulate whether ::apply() has run or not. + // @see \Drupal\package_manager\Stage::TEMPSTORE_CHANGES_APPLIED + $tempstore->get('package_manager_stage')->set('changes_applied', $changes_applied); + $stage->destroy($force, $message); + + // Prove the first stage was destroyed: a second stage can be created + // without an exception being thrown. + $stage2 = $this->createStage(); + $stage2->create(); + + // Claiming the first stage always fails in this test because it was + // destroyed, but the exception message depends on why it was destroyed. + $this->expectException(StageException::class); + $this->expectExceptionMessage($expected_exception_message); + $stage->claim($stage_id); + } + + /** + * Tests exception message once temp store message has expired. + */ + public function testTempStoreMessageExpired(): void { + $stage = $this->createStage(); + $stage_id = $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->destroy(TRUE, t('Force destroy stage.')); + + // Delete the tempstore message stored for the previously destroyed stage. + $tempstore = $this->container->get('tempstore.shared'); + // @see \Drupal\package_manager\Stage::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX + $tempstore->get('package_manager_stage')->delete('TEMPSTORE_DESTROYED_STAGES_INFO' . $stage_id); + + // Claiming the stage will fail, but we won't get the message we set in + // \Drupal\package_manager\Stage::storeDestroyInfo() as we are deleting it + // above. + $this->expectException(StageException::class); + $this->expectExceptionMessage('Cannot claim the stage because no stage has been created.'); + $stage->claim($stage_id); + } + + /** + * Tests running apply and post-apply in the same request. + */ + public function testApplyAndPostApplyInSameRequest(): void { + $stage = $this->createStage(); + + $logger = new TestLogger(); + $stage->setLogger($logger); + $warning_message = 'Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.'; + + // Run apply and post-apply in the same request (i.e., the same request + // time), and ensure the warning is logged. + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $this->assertTrue($logger->hasRecord($warning_message, LogLevel::WARNING)); + $stage->destroy(); + + $logger->reset(); + $stage->create(); + $stage->require(['drupal/core:9.8.2']); + $stage->apply(); + // Simulate post-apply taking place in another request by simulating a + // request time 30 seconds after apply started. + TestTime::$offset = 30; + $stage->postApply(); + $this->assertFalse($logger->hasRecord($warning_message, LogLevel::WARNING)); + } + + /** + * Data provider for ::testFailureDuringComposerStagerOperations(). + * + * @return array[] + * The test cases. + */ + public static function providerFailureDuringComposerStagerOperations(): array { + return [ + [LoggingBeginner::class], + [NoOpStager::class], + [LoggingCommitter::class], + ]; + } + + /** + * Tests when Composer Stager throws an exception during an operation. + * + * @param class-string $throwing_class + * The fully qualified name of the Composer Stager class that should throw + * an exception. It is expected to have a static ::setException() method, + * provided by \Drupal\package_manager_bypass\ComposerStagerExceptionTrait. + * + * @dataProvider providerFailureDuringComposerStagerOperations + */ + public function testFailureDuringComposerStagerOperations(string $throwing_class): void { + $exception_message = "$throwing_class is angry!"; + $throwing_class::setException(\Exception::class, $exception_message, 1024); + + $expected_message = preg_quote($exception_message); + if ($throwing_class === LoggingCommitter::class) { + $expected_message = "/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: $expected_message\nBacktrace:\n#0 .*/"; + } + else { + $expected_message = "/^$expected_message$/"; + } + + $stage = $this->createStage(); + try { + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (StageException $e) { + $this->assertMatchesRegularExpression($expected_message, $e->getMessage()); + $this->assertSame(1024, $e->getCode()); + $this->assertInstanceOf(\Exception::class, $e->getPrevious()); + } + } + + /** + * Tests that paths to exclude are collected before create and apply. + */ + public function testCollectPathsToExclude(): void { + $this->addEventTestListener(function (CollectPathsToExcludeEvent $event): void { + $event->add('exclude/me'); + }, CollectPathsToExcludeEvent::class); + + // On pre-create and pre-apply, ensure that the excluded path is known to + // the event. + $asserted = FALSE; + $assert_excluded = function (object $event) use (&$asserted): void { + $this->assertContains('exclude/me', $event->excludedPaths->getAll()); + // Use this to confirm that this listener was actually called. + $asserted = TRUE; + }; + $this->addEventTestListener($assert_excluded, PreCreateEvent::class); + $this->addEventTestListener($assert_excluded); + + $stage = $this->createStage(); + $stage->create(); + $this->assertTrue($asserted); + $asserted = FALSE; + $stage->require(['ext-json:*']); + $stage->apply(); + $this->assertTrue($asserted); + } + + /** + * Tests that the failure marker file is excluded using a relative path. + */ + public function testFailureMarkerFileExcluded(): void { + $this->assertResults([]); + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $committer_args = $committer->getInvocationArguments(); + $this->assertCount(1, $committer_args); + $this->assertContains('PACKAGE_MANAGER_FAILURE.yml', $committer_args[0][2]); + } + + /** + * Tests that if a stage fails to get paths to exclude, throws a stage exception. + */ + public function testFailureCollectPathsToExclude(): void { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + unlink($project_root . '/composer.json'); + $this->expectException(StageException::class); + $this->expectExceptionMessage("composer.json not found."); + $this->createStage()->create(); + } + + /** + * Tests that if apply fails to get paths to exclude, throws a stage exception. + */ + public function testFailureCollectPathsToExcludeOnApply(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/random']); + $this->expectException(StageException::class); + $this->expectExceptionMessage("composer.json not found."); + unlink($stage->getStageDirectory() . '/composer.json'); + $stage->apply(); + } + + /** + * @covers ::stageDirectoryExists + */ + public function testStageDirectoryExists(): void { + // Ensure that stageDirectoryExists() returns an accurate result during + // pre-create. + $listener = function (StageEvent $event): void { + $stage = $event->stage; + // The directory should not exist yet, because we are still in pre-create. + $this->assertDirectoryDoesNotExist($stage->getStageDirectory()); + $this->assertFalse($stage->stageDirectoryExists()); + }; + $this->addEventTestListener($listener, PreCreateEvent::class); + + $stage = $this->createStage(); + $this->assertFalse($stage->stageDirectoryExists()); + $stage->create(); + $this->assertTrue($stage->stageDirectoryExists()); + } + + /** + * Tests that destroyed stage directories are actually deleted during cron. + * + * @covers ::destroy + * @covers \Drupal\package_manager\Plugin\QueueWorker\Cleaner + */ + public function testStageDirectoryDeletedDuringCron(): void { + $stage = $this->createStage(); + $stage->create(); + $dir = $stage->getStageDirectory(); + $this->assertDirectoryExists($dir); + $stage->destroy(); + // The stage directory should still exist, but the stage should be + // available. + $this->assertTrue($stage->isAvailable()); + $this->assertDirectoryExists($dir); + + $this->container->get('cron')->run(); + $this->assertDirectoryDoesNotExist($dir); + } + +} + +/** + * A test-only implementation of the time service. + */ +class TestTime extends Time { + + /** + * An offset to add to the request time. + * + * @var int + */ + public static $offset = 0; + + /** + * {@inheritdoc} + */ + public function getRequestTime() { + return parent::getRequestTime() + static::$offset; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php new file mode 100644 index 000000000000..8782d27a44c3 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Tests that the stage fires events during its lifecycle. + * + * @covers \Drupal\package_manager\Event\StageEvent + * @group package_manager + * @internal + */ +class StageEventsTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + /** + * The events that were fired, in the order they were fired. + * + * @var string[] + */ + private $events = []; + + /** + * The stage under test. + * + * @var \Drupal\package_manager\StageBase + */ + private $stage; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->stage = $this->createStage(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Since this test adds arbitrary event listeners that aren't services, we + // need to ensure they will persist even if the container is rebuilt when + // staged changes are applied. + $container->getDefinition('event_dispatcher')->addTag('persist'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'handleEvent', + PostCreateEvent::class => 'handleEvent', + PreRequireEvent::class => 'handleEvent', + PostRequireEvent::class => 'handleEvent', + PreApplyEvent::class => 'handleEvent', + PostApplyEvent::class => 'handleEvent', + ]; + } + + /** + * Handles a stage life cycle event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function handleEvent(StageEvent $event): void { + $this->events[] = get_class($event); + + // The event should have a reference to the stage which fired it. + $this->assertSame($event->stage, $this->stage); + } + + /** + * Tests that the stage fires life cycle events in a specific order. + */ + public function testEvents(): void { + $this->container->get('event_dispatcher')->addSubscriber($this); + + $this->stage->create(); + $this->stage->require(['ext-json:*']); + $this->stage->apply(); + $this->stage->postApply(); + $this->stage->destroy(); + + $this->assertSame($this->events, [ + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + ]); + } + + /** + * Data provider for testValidationResults(). + * + * @return string[][] + * The test cases. + */ + public static function providerValidationResults(): array { + return [ + 'PreCreateEvent' => [PreCreateEvent::class], + 'PreRequireEvent' => [PreRequireEvent::class], + 'PreApplyEvent' => [PreApplyEvent::class], + ]; + } + + /** + * Tests that an exception is thrown if an event has validation results. + * + * @param string $event_class + * The event class to test. + * + * @dataProvider providerValidationResults + */ + public function testValidationResults(string $event_class): void { + $error_messages = [t('Burn, baby, burn')]; + // Set up an event listener which will only flag an error for the event + // class under test. + $handler = function (StageEvent $event) use ($event_class, $error_messages): void { + if (get_class($event) === $event_class) { + if ($event instanceof PreOperationStageEvent) { + $event->addError($error_messages); + } + } + }; + $this->addEventTestListener($handler, $event_class); + + $result = ValidationResult::createError($error_messages); + $this->assertResults([$result], $event_class); + } + + /** + * Tests adding validation results to events. + */ + public function testAddResult(): void { + $stage = $this->createStage(); + + $error = ValidationResult::createError([ + t('Burn, baby, burn!'), + ]); + $warning = ValidationResult::createWarning([ + t('The path ahead is scary...'), + ]); + $excluded_paths = $this->createMock(PathListInterface::class); + + // Status check events can accept both errors and warnings. + $event = new StatusCheckEvent($stage, $excluded_paths); + $event->addResult($error); + $event->addResult($warning); + $this->assertSame([$error, $warning], $event->getResults()); + + // Other stage events will accept errors, but throw an exception if you try + // to add a warning. + $event = new PreCreateEvent($stage, $excluded_paths); + $event->addResult($error); + $this->assertSame([$error], $event->getResults()); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only errors are allowed.'); + $event->addResult($warning); + } + + /** + * Tests that pre- and post-require events have access to the package lists. + */ + public function testPackageListsAvailableToRequireEvents(): void { + $listener = function (object $event): void { + $expected_runtime = ['drupal/core' => '9.8.2']; + $expected_dev = ['drupal/core-dev' => '9.8.2']; + + /** @var \Drupal\package_manager\Event\PreRequireEvent|\Drupal\package_manager\Event\PostRequireEvent $event */ + $this->assertSame($expected_runtime, $event->getRuntimePackages()); + $this->assertSame($expected_dev, $event->getDevPackages()); + }; + $this->addEventTestListener($listener, PreRequireEvent::class); + $this->addEventTestListener($listener, PostRequireEvent::class); + + $this->stage->create(); + $this->stage->require(['drupal/core:9.8.2'], ['drupal/core-dev:9.8.2']); + } + + /** + * Tests exception is thrown if error is not added before stopPropagation(). + */ + public function testExceptionIfNoErrorBeforeStopPropagation(): void { + $listener = function (PreCreateEvent $event): void { + $event->stopPropagation(); + }; + $this->addEventTestListener($listener, PreCreateEvent::class); + + $this->expectException(StageEventException::class); + $this->expectExceptionMessage('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.'); + $stage = $this->createStage(); + $stage->create(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php new file mode 100644 index 000000000000..e55c472e2bab --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -0,0 +1,242 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests that ownership of the stage is enforced. + * + * @group package_manager + * @internal + */ +class StageOwnershipTest extends PackageManagerKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'package_manager_test_validation', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * Tests only the owner of stage can perform operations, even if logged out. + */ + public function testOwnershipEnforcedWhenLoggedOut(): void { + $this->assertOwnershipIsEnforced($this->createStage(), $this->createStage()); + } + + /** + * Tests only the owner of stage can perform operations. + */ + public function testOwnershipEnforcedWhenLoggedIn(): void { + $user_1 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_1); + + $will_create = $this->createStage(); + // Rebuild the container so that the shared tempstore factory is made + // properly aware of the new current user ($user_2) before another stage + // is created. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $user_2 = $this->createUser(); + $this->setCurrentUser($user_2); + $this->assertOwnershipIsEnforced($will_create, $this->createStage()); + } + + /** + * Asserts that ownership is enforced across stage directories. + * + * @param \Drupal\Tests\package_manager\Kernel\TestStage $will_create + * The stage that will be created, and owned by the current user or session. + * @param \Drupal\Tests\package_manager\Kernel\TestStage $never_create + * The stage that will not be created, but should still respect the + * ownership and status of the other stage. + */ + private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $never_create): void { + // Before the stage directory is created, isAvailable() should return + // TRUE. + $this->assertTrue($will_create->isAvailable()); + $this->assertTrue($never_create->isAvailable()); + + $stage_id = $will_create->create(); + // Both stage directories should be considered unavailable (i.e., cannot + // be created until the existing one is destroyed first). + $this->assertFalse($will_create->isAvailable()); + $this->assertFalse($never_create->isAvailable()); + + // We should get an error if we try to create the stage directory again, + // regardless of who owns it. + foreach ([$will_create, $never_create] as $stage) { + try { + $stage->create(); + $this->fail("Able to create a stage that already exists."); + } + catch (StageException $exception) { + $this->assertSame('Cannot create a new stage because one already exists.', $exception->getMessage()); + } + } + + try { + $never_create->claim($stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + + // Only the stage's owner should be able to move it through its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'postApply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + try { + $never_create->$method(...$arguments); + $this->fail("Able to call '$method' on a stage that was never created."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // The call should succeed on the created stage. + $will_create->$method(...$arguments); + } + } + + /** + * Tests that the stage is owned by the person who calls create() on it. + */ + public function testStageOwnedByCreator(): void { + // Even if the stage is instantiated before anyone is logged in, it should + // still be owned (and claimable) by the user who called create() on it. + $stage = $this->createStage(); + + $account = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($account); + $id = $stage->create(); + $this->createStage()->claim($id); + } + + /** + * Tests behavior of claiming a stage. + */ + public function testClaim(): void { + // Log in as a user so that any stage instances created during the session + // should be able to successfully call ::claim(). + $user_2 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_2); + $creator_stage = $this->createStage(); + + // Ensure that exceptions thrown during ::create() will not lock the stage. + $error = new \Exception('I am going to stop stage creation.'); + TestSubscriber::setException($error, PreCreateEvent::class); + try { + $creator_stage->create(); + $this->fail('Was able to create the stage despite throwing an exception in pre-create.'); + } + catch (\RuntimeException $exception) { + $this->assertSame($error->getMessage(), $exception->getMessage()); + } + + // The stage should be available, and throw if we try to claim it. + $this->assertTrue($creator_stage->isAvailable()); + try { + $creator_stage->claim('any-id-would-fail'); + $this->fail('Was able to claim a stage that has not been created.'); + } + catch (StageException $exception) { + $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage()); + } + TestSubscriber::setException(NULL, PreCreateEvent::class); + + // Even if we own the stage, we should not be able to claim it with an + // incorrect ID. + $stage_id = $creator_stage->create(); + try { + $this->createStage()->claim('not-correct-id'); + $this->fail('Was able to claim an owned stage with an incorrect ID.'); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because the current lock does not match the stored lock.', $exception->getMessage()); + } + + // A stage that is successfully claimed should be able to call any method + // for its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'postApply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + // Create a new stage instance for each method. + $this->createStage()->claim($stage_id)->$method(...$arguments); + } + + // The stage cannot be claimed after it's been destroyed. + try { + $this->createStage()->claim($stage_id); + $this->fail('Was able to claim an owned stage after it was destroyed.'); + } + catch (StageException $exception) { + $this->assertSame('This operation was already canceled.', $exception->getMessage()); + } + + // Create a new stage and then log in as a different user. + $new_stage_id = $this->createStage()->create(); + $user_3 = $this->createUser([], NULL, FALSE, ['uid' => 3]); + $this->setCurrentUser($user_3); + + // Even if they use the correct stage ID, the current user cannot claim a + // stage they didn't create. + try { + $this->createStage()->claim($new_stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + } + + /** + * Tests a stage being destroyed by a user who doesn't own it. + */ + public function testForceDestroy(): void { + $owned = $this->createStage(); + $owned->create(); + + $not_owned = $this->createStage(); + try { + $not_owned->destroy(); + $this->fail("Able to destroy a stage that we don't own."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // We should be able to destroy the stage if we ignore ownership. + $not_owned->destroy(TRUE); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php new file mode 100644 index 000000000000..86473cc988f3 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php @@ -0,0 +1,207 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\StagedDBUpdateValidator + * @group package_manager + * @internal + */ +class StagedDBUpdateValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->container->get('theme_installer')->install(['stark']); + $this->assertFalse($this->container->get('module_handler')->moduleExists('views')); + $this->assertFalse($this->container->get('theme_handler')->themeExists('olivero')); + + // Ensure that all the extensions we're testing with have database update + // files in the active directory. + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + // System and Stark are installed, so they are used to test what happens + // when database updates are detected in installed extensions. Views and + // Olivero are not installed, so they are used to test what happens when + // non-installed extensions have database updates. + $extensions = [ + 'core/modules/system', + 'core/themes/stark', + 'core/modules/views', + 'core/themes/olivero', + ]; + foreach ($extensions as $extension_path) { + $extension_path = $active_dir . '/' . $extension_path; + mkdir($extension_path, 0777, TRUE); + $extension_name = basename($extension_path); + + // Ensure each extension has a .install and a .post_update.php file with + // an empty update function in it. + foreach (['install', 'post_update.php'] as $suffix) { + $function_name = match ($suffix) { + 'install' => $extension_name . '_update_1000', + 'post_update.php' => $extension_name . '_post_update_test', + }; + file_put_contents("$extension_path/$extension_name.$suffix", "<?php\nfunction $function_name() {}"); + } + } + } + + /** + * Data provider for ::testStagedDatabaseUpdates(). + * + * @return array[] + * The test cases. + */ + public static function providerStagedDatabaseUpdate(): array { + $summary = t('Database updates have been detected in the following extensions.'); + + return [ + 'schema update in installed module' => [ + 'core/modules/system', + 'install', + [ + ValidationResult::createWarning([ + t('System'), + ], $summary), + ], + ], + 'post-update in installed module' => [ + 'core/modules/system', + 'post_update.php', + [ + ValidationResult::createWarning([ + t('System'), + ], $summary), + ], + ], + 'schema update in installed theme' => [ + 'core/themes/stark', + 'install', + [ + ValidationResult::createWarning([ + t('Stark'), + ], $summary), + ], + ], + 'post-update in installed theme' => [ + 'core/themes/stark', + 'post_update.php', + [ + ValidationResult::createWarning([ + t('Stark'), + ], $summary), + ], + ], + // The validator should ignore changes in any extensions that aren't + // installed. + 'schema update in non-installed module' => [ + 'core/modules/views', + 'install', + [], + ], + 'post-update in non-installed module' => [ + 'core/modules/views', + 'post_update.php', + [], + ], + 'schema update in non-installed theme' => [ + 'core/themes/olivero', + 'install', + [], + ], + 'post-update in non-installed theme' => [ + 'core/themes/olivero', + 'post_update.php', + [], + ], + ]; + } + + /** + * Tests validation of staged database updates. + * + * @param string $extension_dir + * The directory of the extension that should have database updates, + * relative to the stage directory. + * @param string $file_extension + * The extension of the update file, without the leading period. Must be + * either `install` or `post_update.php`. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerStagedDatabaseUpdate + */ + public function testStagedDatabaseUpdate(string $extension_dir, string $file_extension, array $expected_results): void { + $extension_name = basename($extension_dir); + $relative_file_path = $extension_dir . '/' . $extension_name . '.' . $file_extension; + + $stage = $this->createStage(); + $stage->create(); + // Nothing has been changed in the stage, so ensure the validator doesn't + // detect any changes. + $this->assertStatusCheckResults([], $stage); + + $staged_update_file = $stage->getStageDirectory() . '/' . $relative_file_path; + $this->assertFileIsWritable($staged_update_file); + + // Now add a "real" update function -- either a schema update or a + // post-update, depending on what $file_extension is -- and ensure that the + // validator detects it. + $update_function_name = match ($file_extension) { + 'install' => $extension_name . '_update_1001', + 'post_update.php' => $extension_name . '_post_update_' . $this->randomMachineName(), + }; + file_put_contents($staged_update_file, "function $update_function_name() {}\n", FILE_APPEND); + $this->assertStatusCheckResults($expected_results, $stage); + + // Add a bunch of functions which are named similarly to real schema update + // and post-update functions, but not quite right, to ensure they are + // ignored by the validator. Also throw an anonymous function in there to + // ensure those are ignored as well. + $code = <<<END +<?php +function {$extension_name}_update() { \$foo = function () {}; } +function {$extension_name}_update_string_123() {} +function {$extension_name}_update__123() {} +function ($extension_name}__post_update_test() {} +function ($extension_name}_post_update() {} +END; + file_put_contents($staged_update_file, $code); + $this->assertStatusCheckResults([], $stage); + + // If the update file is deleted from the stage, the validator should not + // detect any database updates. + unlink($staged_update_file); + $this->assertStatusCheckResults([], $stage); + + // If the update file doesn't exist in the active directory, but does exist + // in the stage with a legitimate schema update or post-update function, the + // validator should detect it. + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + unlink($project_root . '/' . $relative_file_path); + file_put_contents($staged_update_file, "<?php\nfunction $update_function_name() {}"); + $this->assertStatusCheckResults($expected_results, $stage); + } + + /** + * Tests that the validator disregards unclaimed stages. + */ + public function testUnclaimedStage(): void { + $stage = $this->createStage(); + $stage->create(); + $this->assertStatusCheckResults([], $stage); + // A new, unclaimed stage should be ignored by the validator. + $this->assertStatusCheckResults([], $this->createStage()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php b/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php new file mode 100644 index 000000000000..7242405264c8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\StatusCheckTrait + * @group package_manager + * @internal + */ +class StatusCheckTraitTest extends PackageManagerKernelTestBase { + + use StatusCheckTrait; + + /** + * Tests that StatusCheckTrait will collect paths to exclude. + */ + public function testPathsToExcludeCollected(): void { + $this->addEventTestListener(function (CollectPathsToExcludeEvent $event): void { + $event->add('/junk/drawer'); + }, CollectPathsToExcludeEvent::class); + + $status_check_called = FALSE; + $this->addEventTestListener(function (StatusCheckEvent $event) use (&$status_check_called): void { + $this->assertContains('/junk/drawer', $event->excludedPaths->getAll()); + $status_check_called = TRUE; + }, StatusCheckEvent::class); + $this->runStatusCheck($this->createStage(), $this->container->get('event_dispatcher')); + $this->assertTrue($status_check_called); + } + + /** + * Tests that any error will be added to the status check event. + */ + public function testNoErrorIfPathsToExcludeCannotBeCollected(): void { + $e = new \Exception('Not a chance, friend.'); + + $listener = function () use ($e): never { + throw $e; + }; + $this->addEventTestListener($listener, CollectPathsToExcludeEvent::class); + + $excluded_paths_are_null = FALSE; + $listener = function (StatusCheckEvent $event) use (&$excluded_paths_are_null): void { + $excluded_paths_are_null = is_null($event->excludedPaths); + }; + $this->addEventTestListener($listener, StatusCheckEvent::class); + + $this->assertStatusCheckResults([ + ValidationResult::createErrorFromThrowable($e), + ]); + $this->assertTrue($excluded_paths_are_null); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php new file mode 100644 index 000000000000..80bffd511976 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -0,0 +1,243 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator + * @group package_manager + * @internal + */ +class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { + + use FixtureUtilityTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => "drupal/dependency", + 'version' => '9.8.0', + 'type' => 'drupal-library', + ]) + ->addPackage([ + 'name' => "drupal/semver_test", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => "drupal/aaa_update_test", + 'version' => '2.0.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => "drupal/package_manager_theme", + 'version' => '8.1.0', + 'type' => 'drupal-theme', + ]) + ->addPackage([ + 'name' => "somewhere/a_drupal_module", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ]) + ->addPackage( + [ + 'name' => "drupal/module_no_project", + 'version' => '1.0.0', + 'type' => 'drupal-module', + ], + FALSE, + FALSE, + [ + 'module_no_project.info.yml' => '{name: "Module No Project", type: "module"}', + ], + ) + ->commitChanges(); + } + + /** + * Data provider for testException(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerException(): array { + $release_fixture_folder = __DIR__ . '/../../fixtures/release-history'; + $summary = t('Cannot update because the following project version is not in the list of installable releases.'); + return [ + 'semver, supported update' => [ + [ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/semver_test", + 'version' => '8.1.1', + 'type' => 'drupal-module', + ], + [], + ], + 'semver, update to unsupported branch' => [ + [ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/semver_test", + 'version' => '8.2.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('semver_test (drupal/semver_test) 8.2.0')], $summary), + ], + ], + 'legacy, supported update' => [ + [ + 'aaa_update_test' => "$release_fixture_folder/aaa_update_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/aaa_update_test", + 'version' => '2.1.0', + 'type' => 'drupal-module', + ], + [], + ], + 'legacy, update to unsupported branch' => [ + [ + 'aaa_update_test' => "$release_fixture_folder/aaa_update_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/aaa_update_test", + 'version' => '3.0.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('aaa_update_test (drupal/aaa_update_test) 3.0.0')], $summary), + ], + ], + 'package_manager_test_update(not in active), update to unsupported branch' => [ + [ + 'package_manager_test_update' => "$release_fixture_folder/package_manager_test_update.7.0.1.xml", + ], + FALSE, + [ + 'name' => "drupal/package_manager_test_update", + 'version' => '7.0.1-dev', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('package_manager_test_update (drupal/package_manager_test_update) 7.0.1-dev')], $summary), + ], + ], + 'package_manager_test_update(not in active), update to supported branch' => [ + [ + 'package_manager_test_update' => "$release_fixture_folder/package_manager_test_update.7.0.1.xml", + ], + FALSE, + [ + 'name' => "drupal/package_manager_test_update", + 'version' => '7.0.1', + 'type' => 'drupal-module', + ], + [], + ], + 'package_manager_theme, supported update' => [ + [ + 'package_manager_theme' => "$release_fixture_folder/package_manager_theme.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/package_manager_theme", + 'version' => '8.1.1', + 'type' => 'drupal-theme', + ], + [], + ], + 'package_manager_theme, update to unsupported branch' => [ + [ + 'package_manager_theme' => "$release_fixture_folder/package_manager_theme.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/package_manager_theme", + 'version' => '8.2.0', + 'type' => 'drupal-theme', + ], + [ + ValidationResult::createError([t('package_manager_theme (drupal/package_manager_theme) 8.2.0')], $summary), + ], + ], + // For modules that don't start with 'drupal/' will not have update XML + // from drupal.org and so will not be checked by the validator. + // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases() + 'updating a module that does not start with drupal/' => [ + [], + TRUE, + [ + 'name' => "somewhere/a_drupal_module", + 'version' => '8.1.1', + 'type' => 'drupal-module', + ], + [], + ], + 'updating a module that does not have project info' => [ + [], + TRUE, + [ + 'name' => "drupal/module_no_project", + 'version' => '1.1.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('Cannot update because the following new or updated Drupal package does not have project information: drupal/module_no_project')]), + ], + ], + ]; + } + + /** + * Tests exceptions when updating to unsupported or insecure releases. + * + * @param array $release_metadata + * Array of paths of the fake release metadata keyed by project name. + * @param bool $project_in_active + * Whether the project is in the active directory or not. + * @param array $package + * The package that will be added or modified. + * @param array $expected_results + * The expected validation results. + * + * @dataProvider providerException + */ + public function testException(array $release_metadata, bool $project_in_active, array $package, array $expected_results): void { + $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'] + $release_metadata); + + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($project_in_active) { + $stage_manipulator->setVersion($package['name'], $package['version']); + } + else { + $stage_manipulator->addPackage($package); + } + // We always update this module to prove that the validator will skip this + // module as it's of type 'drupal-library'. + // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases() + $stage_manipulator->setVersion('drupal/dependency', '9.8.1'); + $this->assertResults($expected_results, PreApplyEvent::class); + // Ensure that any errors arising from invalid project info (which we expect + // in this test) will not fail the test during tear-down. + $this->failureLogger->reset(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php new file mode 100644 index 000000000000..46d36bfb83c4 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php @@ -0,0 +1,203 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Environment\Service\EnvironmentInterface; +use Prophecy\Argument; + +/** + * @covers \Drupal\package_manager\Validator\SymlinkValidator + * @group package_manager + * @internal + */ +class SymlinkValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests that relative symlinks within the same package are supported. + */ + public function testSymlinksWithinSamePackage(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $drush_dir = $project_root . '/vendor/drush/drush'; + mkdir($drush_dir . '/docs', 0777, TRUE); + touch($drush_dir . '/drush_logo-black.png'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($drush_dir . '/docs'); + symlink('../drush_logo-black.png', 'drush_logo-black.png'); + + // Switch back to the Drupal root to ensure that the check isn't affected + // by which directory we happen to be in. + chdir($this->getDrupalRoot()); + $this->assertStatusCheckResults([]); + } + + /** + * Tests that hard links are not supported. + */ + public function testHardLinks(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + link($project_root . '/composer.json', $project_root . '/composer.link'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains hard links, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.json', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that symlinks with absolute paths are not supported. + */ + public function testAbsoluteSymlinks(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + symlink($project_root . '/composer.json', $project_root . '/composer.link'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains absolute links, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.link', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that relative symlinks cannot point outside the project root. + */ + public function testSymlinkPointingOutsideProjectRoot(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $parent_dir = dirname($project_root); + touch($parent_dir . '/hello.txt'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root); + symlink('../hello.txt', 'fail.txt'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/fail.txt', + ]), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that relative symlinks cannot point outside the stage directory. + */ + public function testSymlinkPointingOutsideStageDirectory(): void { + // The same check should apply to symlinks in the stage directory that + // point outside of it. + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + $stage_dir = $stage->getStageDirectory(); + $parent_dir = dirname($stage_dir); + touch($parent_dir . '/hello.txt'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($stage_dir); + symlink('../hello.txt', 'fail.txt'); + + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [ + '%which' => 'staging', + '%dir' => $stage_dir, + '%file' => $stage_dir . '/fail.txt', + ]), + ]); + try { + $stage->apply(); + $this->fail('Expected an exception, but none was thrown.'); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException([$result], $e); + } + } + + /** + * Tests what happens when there is a symlink to a directory. + */ + public function testSymlinkToDirectory(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + mkdir($project_root . '/modules/custom'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root . '/modules/custom'); + symlink('../example', 'example_module'); + + // Switch back to the Drupal root to ensure that the check isn't affected + // by which directory we happen to be in. + chdir($this->getDrupalRoot()); + $this->assertStatusCheckResults([]); + } + + /** + * Tests that symlinks are not supported on Windows, even if they're safe. + */ + public function testSymlinksNotAllowedOnWindows(): void { + $environment = $this->prophesize(EnvironmentInterface::class); + $environment->isWindows()->willReturn(TRUE); + $environment->setTimeLimit(Argument::type('int'))->willReturn(TRUE); + $this->container->set(EnvironmentInterface::class, $environment->reveal()); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root); + symlink('composer.json', 'composer.link'); + + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links, which is not supported on Windows. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.link', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that unsupported links are excluded if they're under excluded paths. + * + * @depends testAbsoluteSymlinks + * + * @covers \Drupal\package_manager\PathExcluder\GitExcluder + * @covers \Drupal\package_manager\PathExcluder\NodeModulesExcluder + */ + public function testUnsupportedLinkUnderExcludedPath(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + // Create absolute symlinks (which are not supported by Composer Stager) in + // both `node_modules`, which is a regular directory, and `.git`, which is a + // hidden directory. + mkdir($project_root . '/node_modules'); + symlink($project_root . '/composer.json', $project_root . '/node_modules/composer.link'); + symlink($project_root . '/composer.json', $project_root . '/.git/composer.link'); + + $this->assertStatusCheckResults([]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php b/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php new file mode 100644 index 000000000000..4f7aaecec154 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\TranslatableStringAdapter; +use Drupal\package_manager\TranslatableStringFactory; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; + +/** + * @covers \Drupal\package_manager\TranslatableStringFactory + * @covers \Drupal\package_manager\TranslatableStringAdapter + * + * @group package_manager + */ +class TranslatableStringTest extends PackageManagerKernelTestBase { + + /** + * Tests various ways of creating a translatable string. + */ + public function testCreateTranslatableString(): void { + // Ensure that we have properly overridden Composer Stager's factory. + $factory = $this->container->get(TranslatableFactoryInterface::class); + $this->assertInstanceOf(TranslatableStringFactory::class, $factory); + + /** @var \Drupal\package_manager\TranslatableStringAdapter $string */ + $string = $factory->createTranslatableMessage('This string has no parameters.'); + $this->assertInstanceOf(TranslatableStringAdapter::class, $string); + $this->assertEmpty($string->getArguments()); + $this->assertEmpty($string->getOption('context')); + $this->assertSame('This string has no parameters.', (string) $string); + + $parameters = $factory->createTranslationParameters([ + '%name' => 'Slim Shady', + ]); + $string = $factory->createTranslatableMessage('My name is %name.', $parameters, 'outer space'); + $this->assertSame($parameters->getAll(), $string->getArguments()); + $this->assertSame('outer space', $string->getOption('context')); + $this->assertSame('My name is <em class="placeholder">Slim Shady</em>.', (string) $string); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php new file mode 100644 index 000000000000..c18baf6b4454 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php @@ -0,0 +1,265 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Unit tests the file system permissions validator. + * + * This validator is tested functionally in Automatic Updates' build tests, + * since those give us control over the file system permissions. + * + * @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + * + * @covers \Drupal\package_manager\Validator\WritableFileSystemValidator + * @group package_manager + * @internal + */ +class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testWritable(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerWritable(): array { + // @see \Drupal\Tests\package_manager\Traits\ValidationTestTrait::resolvePlaceholdersInArrayValuesWithRealPaths() + $drupal_root_error = t('The Drupal directory "<PROJECT_ROOT>/web" is not writable.'); + $vendor_error = t('The vendor directory "<VENDOR_DIR>" is not writable.'); + $project_root_error = t('The project root directory "<PROJECT_ROOT>" is not writable.'); + $summary = t('The file system is not writable.'); + $writable_permission = 0777; + $non_writable_permission = 0550; + + return [ + 'root and vendor are writable, nested web root' => [ + $writable_permission, + $writable_permission, + $writable_permission, + 'web', + [], + ], + 'root writable, vendor not writable, nested web root' => [ + $writable_permission, + $writable_permission, + $non_writable_permission, + 'web', + [ + ValidationResult::createError([$vendor_error], $summary), + ], + ], + 'root not writable, vendor writable, nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $writable_permission, + 'web', + [ + ValidationResult::createError([$drupal_root_error, $project_root_error], $summary), + ], + ], + 'nothing writable, nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $non_writable_permission, + 'web', + [ + ValidationResult::createError([$drupal_root_error, $project_root_error, $vendor_error], $summary), + ], + ], + 'root and vendor are writable, non-nested web root' => [ + $writable_permission, + $writable_permission, + $writable_permission, + '', + [], + ], + 'root writable, vendor not writable, non-nested web root' => [ + $writable_permission, + $writable_permission, + $non_writable_permission, + '', + [ + ValidationResult::createError([$vendor_error], $summary), + ], + ], + 'root not writable, vendor writable, non-nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $writable_permission, + '', + [ + ValidationResult::createError([$project_root_error], $summary), + ], + ], + 'nothing writable, non-nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $non_writable_permission, + '', + [ + ValidationResult::createError([$project_root_error, $vendor_error], $summary), + ], + ], + ]; + } + + /** + * Tests the file system permissions validator. + * + * @param int $root_permissions + * The file permissions for the root folder. + * @param int $webroot_permissions + * The file permissions for the web root folder. + * @param int $vendor_permissions + * The file permissions for the vendor folder. + * @param string $webroot_relative_directory + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerWritable + */ + public function testWritable(int $root_permissions, int $webroot_permissions, int $vendor_permissions, string $webroot_relative_directory, array $expected_results): void { + $this->setUpPermissions($root_permissions, $webroot_permissions, $vendor_permissions, $webroot_relative_directory); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests the file system permissions validator during pre-apply. + * + * @param int $root_permissions + * The file permissions for the root folder. + * @param int $webroot_permissions + * The file permissions for the web root folder. + * @param int $vendor_permissions + * The file permissions for the vendor folder. + * @param string $webroot_relative_directory + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerWritable + */ + public function testWritableDuringPreApply(int $root_permissions, int $webroot_permissions, int $vendor_permissions, string $webroot_relative_directory, array $expected_results): void { + $this->addEventTestListener( + function () use ($webroot_permissions, $root_permissions, $vendor_permissions, $webroot_relative_directory): void { + $this->setUpPermissions($root_permissions, $webroot_permissions, $vendor_permissions, $webroot_relative_directory); + + // During pre-apply we don't care whether the staging root is writable. + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + $this->assertTrue(chmod($path_locator->getStagingRoot(), 0550)); + }, + ); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Sets the permissions of the test project's directories. + * + * @param int $root_permissions + * The permissions for the project root. + * @param int $web_root_permissions + * The permissions for the web root. + * @param int $vendor_permissions + * The permissions for the vendor directory. + * @param string $relative_web_root + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + */ + private function setUpPermissions(int $root_permissions, int $web_root_permissions, int $vendor_permissions, string $relative_web_root): void { + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $project_root = $web_root = $path_locator->getProjectRoot(); + $vendor_dir = $path_locator->getVendorDirectory(); + // Create the web root directory, if necessary. + if (!empty($relative_web_root)) { + $web_root .= '/' . $relative_web_root; + mkdir($web_root); + } + $path_locator->setPaths($project_root, $vendor_dir, $relative_web_root, $path_locator->getStagingRoot()); + + // We need to set the vendor directory and web root permissions first + // because they may be located inside the project root. + $this->assertTrue(chmod($vendor_dir, $vendor_permissions)); + if ($project_root !== $web_root) { + $this->assertTrue(chmod($web_root, $web_root_permissions)); + } + $this->assertTrue(chmod($project_root, $root_permissions)); + } + + /** + * Data provider for ::testStagingRootPermissions(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerStagingRootPermissions(): array { + $writable_permission = 0777; + $non_writable_permission = 0550; + $summary = t('The file system is not writable.'); + return [ + 'writable stage root exists' => [ + $writable_permission, + [], + FALSE, + ], + 'write-protected stage root exists' => [ + $non_writable_permission, + [ + ValidationResult::createError([t('The stage root directory "<STAGE_ROOT>" is not writable.')], $summary), + ], + FALSE, + ], + 'stage root directory does not exist, parent directory not writable' => [ + $non_writable_permission, + [ + ValidationResult::createError([t('The stage root directory will not able to be created at "<STAGE_ROOT_PARENT>".')], $summary), + ], + TRUE, + ], + ]; + } + + /** + * Tests that the stage root's permissions are validated. + * + * @param int $permissions + * The file permissions to apply to the stage root directory, or its parent + * directory, depending on the value of $delete_staging_root. + * @param array $expected_results + * The expected validation results. + * @param bool $delete_staging_root + * Whether the stage root directory will exist at all. + * + * @dataProvider providerStagingRootPermissions + */ + public function testStagingRootPermissions(int $permissions, array $expected_results, bool $delete_staging_root): void { + $dir = $this->container->get(PathLocator::class) + ->getStagingRoot(); + + if ($delete_staging_root) { + $fs = new Filesystem(); + $fs->remove($dir); + $dir = dirname($dir); + } + $this->assertTrue(chmod($dir, $permissions)); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php b/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php new file mode 100644 index 000000000000..c55bfebf56f4 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php @@ -0,0 +1,105 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Composer\Autoload\ClassLoader; + +/** + * Asserts preconditions for tests to function properly. + */ +trait AssertPreconditionsTrait { + + /** + * Invokes the test preconditions assertion before the first test is run. + * + * "Use" this trait on any Package Manager test class that directly extends a + * Core test class, i.e., any class that does NOT extend a test class in a + * Package Manager test namespace. If that class implements this method, too, + * be sure to call this first thing in it. + */ + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + static::failIfUnmetPreConditions('before'); + } + + /** + * Invokes the test preconditions assertion after each test run. + * + * This ensures that no test method leaves behind violations of test + * preconditions. This makes it trivial to discover broken tests. + */ + protected function tearDown(): void { + parent::tearDown(); + static::failIfUnmetPreConditions('after'); + } + + /** + * Asserts universal test preconditions before any setup is done. + * + * If these preconditions aren't met, automated tests will absolutely fail + * needlessly with misleading errors. In that case, there's no reason to even + * begin. + * + * Ordinarily, these preconditions would be asserted in + * ::assertPreConditions(), which PHPUnit provides for exactly this use case. + * Unfortunately, that method doesn't run until after ::setUp(), so our (many) + * tests with expensive, time-consuming setup routines wouldn't actually fail + * very early. + * + * @param string $when + * Either 'before' (before any test methods run) or 'after' (after any test + * method finishes). + * + * @see \PHPUnit\Framework\TestCase::assertPreConditions() + * @see \PHPUnit\Framework\TestCase::setUpBeforeClass() + * @see self::setupBeforeClass() + * @see self::tearDown() + */ + protected static function failIfUnmetPreConditions(string $when): void { + assert(in_array($when, ['before', 'after'], TRUE)); + static::assertNoFailureMarker($when); + } + + /** + * Asserts that there is no failure marker present. + * + * @param string $when + * Either 'before' (before any test methods run) or 'after' (after any test + * method finishes). + * + * @see \Drupal\package_manager\FailureMarker + */ + private static function assertNoFailureMarker(string $when): void { + // If the failure marker exists, it will be in the project root. The project + // root is defined as the directory containing the `vendor` directory. + // @see \Drupal\package_manager\FailureMarker::getPath() + $failure_marker = static::getProjectRoot() . '/PACKAGE_MANAGER_FAILURE.yml'; + if (file_exists($failure_marker)) { + $suffix = $when === 'before' + ? 'Remove it to continue.' + : 'This test method created this marker but failed to clean up after itself.'; + static::fail("The failure marker '$failure_marker' is present in the project. $suffix"); + } + } + + /** + * Returns the absolute path of the project root. + * + * @return string + * The absolute path of the project root. + * + * @see \Drupal\package_manager\PathLocator::getProjectRoot() + */ + private static function getProjectRoot(): string { + // This is tricky, because this method has to be static (since + // ::setUpBeforeClass is), so it can't just get the container from an + // instance member. + // Use reflection to extract the vendor directory from the class loader. + $class_loaders = ClassLoader::getRegisteredLoaders(); + $vendor_directory = key($class_loaders); + return dirname($vendor_directory); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php b/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php new file mode 100644 index 000000000000..b946e987af94 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Composer\InstalledVersions; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Symfony\Component\Process\Process; + +/** + * A utility for kernel tests that need to use 'composer/installers'. + * + * @internal + */ +trait ComposerInstallersTrait { + + /** + * Installs the composer/installers package. + * + * @param string $dir + * The fixture directory to install into. + */ + private function installComposerInstallers(string $dir): void { + $package_name = 'composer/installers'; + $this->assertTrue(InstalledVersions::isInstalled($package_name)); + + $repository = json_encode([ + 'type' => 'path', + 'url' => InstalledVersions::getInstallPath($package_name), + 'options' => [ + 'symlink' => FALSE, + 'versions' => [ + // Explicitly state the version contained by this path repository, + // otherwise Composer will infer the version based on the git clone or + // fall back to `dev-master`. + // @see https://getcomposer.org/doc/05-repositories.md#path + 'composer/installers' => InstalledVersions::getVersion($package_name), + ], + ], + ], JSON_UNESCAPED_SLASHES); + $working_dir_option = "--working-dir=$dir"; + (new Process(['composer', 'config', 'repo.composer-installers-real', $repository, $working_dir_option]))->mustRun(); + (new FixtureManipulator()) + ->addConfig(['allow-plugins.composer/installers' => TRUE]) + ->commitChanges($dir); + (new Process(['composer', 'require', 'composer/installers:@dev', $working_dir_option]))->mustRun(); + + // Use the default installer paths for Drupal core and extensions. + $this->setInstallerPaths([], $dir); + } + + /** + * Sets the installer paths config. + * + * @param array $installer_paths + * The installed paths. + * @param string $directory + * The fixture directory. + */ + private function setInstallerPaths(array $installer_paths, string $directory): void { + // Respect any existing installer paths. + $extra = $this->container->get(ComposerInspector::class) + ->getConfig('extra', $directory . '/composer.json'); + $existing_installer_paths = json_decode($extra, TRUE, flags: JSON_THROW_ON_ERROR)['installer-paths'] ?? []; + + (new FixtureManipulator()) + ->addConfig([ + 'extra.installer-paths' => $installer_paths + $existing_installer_paths, + ]) + ->commitChanges($directory); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php b/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php new file mode 100644 index 000000000000..9f76afd90581 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface; + +/** + * Contains helper methods for testing Composer Stager interactions. + * + * @internal + * + * @property \Symfony\Component\DependencyInjection\ContainerInterface $container + */ +trait ComposerStagerTestTrait { + + /** + * Creates a Composer Stager translatable message. + * + * @param string $message + * A message containing optional placeholders corresponding to parameters (next). Example: + * ```php + * $message = 'Hello, %first_name %last_name.'; + * ``` + * @param \PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface|null $parameters + * Translation parameters. + * @param string|null $domain + * An arbitrary domain for grouping translations or null to use the default. See + * {@see \PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface}. + * + * @return \PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface + * A message that can be translated by Composer Stager. + */ + protected function createComposeStagerMessage( + string $message, + ?TranslationParametersInterface $parameters = NULL, + ?string $domain = NULL, + ): TranslatableInterface { + /** @var \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $translatable_factory */ + $translatable_factory = $this->container->get(TranslatableFactoryInterface::class); + + return $translatable_factory->createTranslatableMessage($message, $parameters, $domain); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php b/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php new file mode 100644 index 000000000000..4d6939aa875f --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\fixture_manipulator\StageFixtureManipulator; + +/** + * A trait for common fixture manipulator functions. + */ +trait FixtureManipulatorTrait { + + /** + * Gets the stage fixture manipulator service. + * + * @return \Drupal\fixture_manipulator\StageFixtureManipulator|object|null + * The stage fixture manipulator service. + */ + protected function getStageFixtureManipulator() { + return $this->container->get(StageFixtureManipulator::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php new file mode 100644 index 000000000000..e2fc313b5434 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator; + +/** + * A utility for all things fixtures. + * + * @internal + */ +trait FixtureUtilityTrait { + + /** + * Mirrors a fixture directory to the given path. + * + * Files not in the source fixture directory will not be deleted from + * destination directory. After copying the files to the destination directory + * the files and folders will be converted so that can be used in the tests. + * The conversion includes: + * - Renaming '_git' directories to '.git' + * - Renaming files ending in '.info.yml.hide' to remove '.hide'. + * + * @param string $source_path + * The source path. + * @param string $destination_path + * The path to which the fixture files should be mirrored. + */ + protected static function copyFixtureFilesTo(string $source_path, string $destination_path): void { + (new Filesystem())->mirror($source_path, $destination_path, NULL, [ + 'override' => TRUE, + 'delete' => FALSE, + ]); + static::renameInfoYmlFiles($destination_path); + static::renameGitDirectories($destination_path); + } + + /** + * Renames all files that end with .info.yml.hide. + * + * @param string $dir + * The directory to be iterated through. + */ + protected static function renameInfoYmlFiles(string $dir): void { + // Construct the iterator. + $it = new RecursiveDirectoryIterator($dir, \RecursiveIteratorIterator::SELF_FIRST); + + // Loop through files and rename them. + foreach (new \RecursiveIteratorIterator($it) as $file) { + if ($file->getExtension() == 'hide') { + rename($file->getPathname(), $dir . DIRECTORY_SEPARATOR . + $file->getRelativePath() . DIRECTORY_SEPARATOR . str_replace(".hide", "", $file->getFilename())); + } + } + } + + /** + * Renames _git directories to .git. + * + * @param string $dir + * The directory to be iterated through. + */ + private static function renameGitDirectories(string $dir): void { + $iter = new \RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($iter as $file) { + if ($file->isDir() && $file->getFilename() === '_git' && $file->getRelativePathname()) { + rename( + $file->getPathname(), + $file->getPath() . DIRECTORY_SEPARATOR . '.git' + ); + } + } + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php b/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php new file mode 100644 index 000000000000..223fe7198082 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; + +/** + * A trait for comparing InstalledPackagesList objects. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +trait InstalledPackagesListTrait { + + /** + * Asserts that 2 installed package lists are equal. + * + * @param \Drupal\package_manager\InstalledPackagesList $expected_list + * The expected list. + * @param \Drupal\package_manager\InstalledPackagesList $actual_list + * The actual list. + */ + private function assertPackageListsEqual(InstalledPackagesList $expected_list, InstalledPackagesList $actual_list): void { + $expected_array = $expected_list->getArrayCopy(); + $actual_array = $actual_list->getArrayCopy(); + ksort($expected_array); + ksort($actual_array); + $this->assertSame(array_keys($expected_array), array_keys($actual_array)); + foreach ($expected_list as $package_name => $expected_package) { + $this->assertInstanceOf(InstalledPackage::class, $expected_package); + $actual_package = $actual_list[$package_name]; + $this->assertInstanceOf(InstalledPackage::class, $actual_package); + $this->assertSame($expected_package->name, $actual_package->name); + $this->assertSame($expected_package->version, $actual_package->version); + $this->assertSame($expected_package->path, $actual_package->path); + $this->assertSame($expected_package->type, $actual_package->type); + $this->assertSame($expected_package->getProjectName(), $actual_package->getProjectName()); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php new file mode 100644 index 000000000000..797a84f8a4d3 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; + +/** + * Common functions for testing using the package_manager_bypass module. + * + * @internal + */ +trait PackageManagerBypassTestTrait { + + /** + * Asserts the number of times an update was staged. + * + * @param int $attempted_times + * The expected number of times an update was staged. + */ + protected function assertUpdateStagedTimes(int $attempted_times): void { + /** @var \Drupal\package_manager_bypass\LoggingBeginner $beginner */ + $beginner = $this->container->get(BeginnerInterface::class); + $this->assertCount($attempted_times, $beginner->getInvocationArguments()); + + /** @var \Drupal\package_manager_bypass\NoOpStager $stager */ + $stager = $this->container->get(StagerInterface::class); + // If an update was attempted, then there will be at least two calls to the + // stager: one to change the runtime constraints in composer.json, and + // another to actually update the installed dependencies. If any dev + // packages (like `drupal/core-dev`) are installed, there may also be an + // additional call to change the dev constraints. + $this->assertGreaterThanOrEqual($attempted_times * 2, count($stager->getInvocationArguments())); + + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $this->assertEmpty($committer->getInvocationArguments()); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php b/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php new file mode 100644 index 000000000000..75f41f1a1568 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php @@ -0,0 +1,130 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\UnitTestCase; + +/** + * Contains helpful methods for testing stage validators. + * + * @internal + */ +trait ValidationTestTrait { + + /** + * Asserts two validation result sets are equal. + * + * This assertion is sensitive to the order of results. For example, + * ['a', 'b'] is not equal to ['b', 'a']. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param \Drupal\package_manager\ValidationResult[] $actual_results + * The actual validation results. + * @param \Drupal\package_manager\PathLocator|null $path_locator + * (optional) The path locator (when this trait is used in unit tests). + * @param string|null $stage_dir + * (optional) The stage directory. + */ + protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): void { + if ($path_locator) { + assert(is_a(get_called_class(), UnitTestCase::class, TRUE)); + } + $expected_results = array_map( + function (array $result) use ($path_locator, $stage_dir): array { + $result['messages'] = $this->resolvePlaceholdersInArrayValuesWithRealPaths($result['messages'], $path_locator, $stage_dir); + return $result; + }, + $this->getValidationResultsAsArray($expected_results) + ); + $actual_results = $this->getValidationResultsAsArray($actual_results); + + self::assertSame($expected_results, $actual_results); + } + + /** + * Resolves <PROJECT_ROOT>, <VENDOR_DIR>, <STAGE_ROOT>, <STAGE_ROOT_PARENT>. + * + * @param array $subject + * An array with arbitrary keys, and values potentially containing the + * placeholders <PROJECT_ROOT>, <VENDOR_DIR>, <STAGE_ROOT>, or + * <STAGE_ROOT_PARENT>. <STAGE_DIR> is the placeholder for $stage_dir, if + * passed. + * @param \Drupal\package_manager\PathLocator|null $path_locator + * (optional) The path locator (when this trait is used in unit tests). + * @param string|null $stage_dir + * (optional) The stage directory. + * + * @return array + * The same array, with unchanged keys, and with the placeholders resolved. + */ + protected function resolvePlaceholdersInArrayValuesWithRealPaths(array $subject, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): array { + if (!$path_locator) { + // Only kernel and browser tests have $this->container. + assert($this instanceof KernelTestBase || $this instanceof BrowserTestBase); + $path_locator = $this->container->get(PathLocator::class); + } + $subject = str_replace( + ['<PROJECT_ROOT>', '<VENDOR_DIR>', '<STAGE_ROOT>', '<STAGE_ROOT_PARENT>'], + [$path_locator->getProjectRoot(), $path_locator->getVendorDirectory(), $path_locator->getStagingRoot(), dirname($path_locator->getStagingRoot())], + $subject + ); + if ($stage_dir) { + $subject = str_replace(['<STAGE_DIR>'], [$stage_dir], $subject); + } + foreach ($subject as $message) { + if (str_contains($message, '<STAGE_DIR>')) { + throw new \LogicException("No stage directory passed to replace '<STAGE_DIR>' in message '$message'"); + } + } + return $subject; + } + + /** + * Gets an array representation of validation results for easy comparison. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * An array of validation results. + * + * @return array + * An array of validation results details: + * - severity: (int) The severity code. + * - messages: (array) An array of strings. + * - summary: (string|null) A summary string if there is one or NULL if not. + */ + protected function getValidationResultsAsArray(array $results): array { + $string_translation_stub = NULL; + if (is_a(get_called_class(), UnitTestCase::class, TRUE)) { + assert($this instanceof UnitTestCase); + $string_translation_stub = $this->getStringTranslationStub(); + } + return array_values(array_map(static function (ValidationResult $result) use ($string_translation_stub) { + $messages = array_map(static function ($message) use ($string_translation_stub): string { + // Support data providers in unit tests using TranslatableMarkup. + if ($message instanceof TranslatableMarkup && is_a(get_called_class(), UnitTestCase::class, TRUE)) { + $message = new TranslatableMarkup($message->getUntranslatedString(), $message->getArguments(), $message->getOptions(), $string_translation_stub); + } + return (string) $message; + }, $result->messages); + + $summary = $result->summary; + if ($summary !== NULL) { + $summary = (string) $result->summary; + } + + return [ + 'severity' => $result->severity, + 'messages' => $messages, + 'summary' => $summary, + ]; + }, $results)); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php b/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php new file mode 100644 index 000000000000..811d7547e04e --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\ExecutableFinder; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * @covers \Drupal\package_manager\ExecutableFinder + * @group package_manager + * @internal + */ +class ExecutableFinderTest extends UnitTestCase { + + /** + * Tests that the executable finder looks for paths in configuration. + */ + public function testCheckConfigurationForExecutablePath(): void { + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => [ + 'executables' => [ + 'composer' => '/path/to/composer', + ], + ], + ]); + + $decorated = $this->prophesize(ExecutableFinderInterface::class); + $decorated->find('composer')->shouldNotBeCalled(); + $decorated->find('rsync')->shouldBeCalled(); + + $finder = new ExecutableFinder($decorated->reveal(), $config_factory); + $this->assertSame('/path/to/composer', $finder->find('composer')); + $finder->find('rsync'); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php b/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php new file mode 100644 index 000000000000..5cd31fc3e039 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackage + * + * @group package_manager + */ +class InstalledPackageTest extends UnitTestCase { + + /** + * @covers ::createFromArray + * + * @depends testMetapackageWithAPath + */ + public function testPathResolution(): void { + // Metapackages must be created without a path. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'metapackage', + 'version' => '1.0.0', + 'path' => NULL, + ]); + $this->assertNull($package->path); + + // Paths should be converted to real paths. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => __DIR__ . '/..', + ]); + $this->assertSame(realpath(__DIR__ . '/..'), $package->path); + + // If we provide a path that cannot be resolved to a real path, it should + // raise an error. + $this->expectException(\TypeError::class); + InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => $this->getRandomGenerator()->string(), + ]); + } + + /** + * @covers ::createFromArray + */ + public function testMetapackageWithAPath(): void { + $this->expectException(\AssertionError::class); + $this->expectExceptionMessage('Metapackage install path must be NULL.'); + + InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'metapackage', + 'version' => '1.0.0', + 'path' => __DIR__, + ]); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php b/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php new file mode 100644 index 000000000000..ca8c40212228 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends UnitTestCase { + + /** + * @covers ::offsetSet + * @covers ::offsetUnset + * @covers ::append + * @covers ::exchangeArray + * + * @testWith ["offsetSet", ["new", "thing"]] + * ["offsetUnset", ["existing"]] + * ["append", ["new thing"]] + * ["exchangeArray", [{"evil": "twin"}]] + */ + public function testImmutability(string $method, array $arguments): void { + $list = new InstalledPackagesList(['existing' => 'thing']); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Installed package lists cannot be modified.'); + $list->$method(...$arguments); + } + + /** + * @covers ::getPackagesNotIn + * @covers ::getPackagesWithDifferentVersionsIn + */ + public function testPackageComparison(): void { + $active = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/removed' => InstalledPackage::createFromArray([ + 'name' => 'drupal/removed', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + ]); + $staged = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.1.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/added' => InstalledPackage::createFromArray([ + 'name' => 'drupal/added', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + ]); + + $added = $staged->getPackagesNotIn($active)->getArrayCopy(); + $this->assertSame(['drupal/added'], array_keys($added)); + + $removed = $active->getPackagesNotIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/removed'], array_keys($removed)); + + $updated = $active->getPackagesWithDifferentVersionsIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/updated'], array_keys($updated)); + } + + /** + * @covers ::getCorePackages + */ + public function testCorePackages(): void { + $data = [ + 'drupal/core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core', + 'version' => \Drupal::VERSION, + 'type' => 'drupal-core', + 'path' => __DIR__, + ]), + 'drupal/core-dev' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-dev-pinned' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev-pinned', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-composer-scaffold' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-composer-scaffold', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ]), + 'drupal/core-project-message' => [ + 'name' => 'drupal/core-project-message', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ], + 'drupal/core-vendor-hardening' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-vendor-hardening', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ]), + 'drupal/not-core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/not-core', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => __DIR__, + ]), + ]; + + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/not-core', $list->getCorePackages()); + + // Tests that we don't get core packages intended for development when + // include_dev is set to FALSE. + $core_packages_no_dev = $list->getCorePackages(FALSE); + $this->assertArrayNotHasKey('drupal/core-dev', $core_packages_no_dev); + $this->assertArrayNotHasKey('drupal/core-dev-pinned', $core_packages_no_dev); + // We still get other packages as intended. + $this->assertArrayHasKey('drupal/core', $core_packages_no_dev); + + // If drupal/core-recommended is in the list, it should supersede + // drupal/core. + $this->assertArrayHasKey('drupal/core', $list->getCorePackages()); + $data['drupal/core-recommended'] = InstalledPackage::createFromArray([ + 'name' => 'drupal/core-recommended', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]); + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/core', $list->getCorePackages()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php b/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php new file mode 100644 index 000000000000..af84befb7dab --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingBeginner; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; + +/** + * @covers \Drupal\package_manager\LoggingBeginner + * @group package_manager + */ +class LoggingBeginnerTest extends UnitTestCase { + + public function testDecoratedBeginnerIsCalled(): void { + $decorated = $this->createMock(BeginnerInterface::class); + + $activeDir = $this->createMock(PathInterface::class); + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + + $decorated->expects($this->once()) + ->method('begin') + ->with( + $activeDir, + $stagingDir, + NULL, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $time = $this->createMock(TimeInterface::class); + $time->expects($this->atLeast(2)) + ->method('getCurrentMicroTime') + ->willReturnOnConsecutiveCalls(1, 2.5); + + $callback = new ProcessOutputCallback(); + + (new LoggingBeginner($decorated, $config_factory, $time)) + ->begin($activeDir, $stagingDir, callback: $callback); + + $this->assertSame([ + "### Beginning in staging-dir\n", + "### Finished in 1.500 seconds\n", + ], $callback->getOutput()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php b/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php new file mode 100644 index 000000000000..3d6ab55de0cf --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingCommitter; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; + +/** + * @covers \Drupal\package_manager\LoggingCommitter + * @group package_manager + */ +class LoggingCommitterTest extends UnitTestCase { + + public function testDecoratedCommitterIsCalled(): void { + $decorated = $this->createMock(CommitterInterface::class); + + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + $activeDir = $this->createMock(PathInterface::class); + $activeDir->expects($this->any()) + ->method('absolute') + ->willReturn('active-dir'); + + $decorated->expects($this->once()) + ->method('commit') + ->with( + $stagingDir, + $activeDir, + NULL, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $time = $this->createMock(TimeInterface::class); + $time->expects($this->atLeast(2)) + ->method('getCurrentMicroTime') + ->willReturnOnConsecutiveCalls(1, 2.5); + + $callback = new ProcessOutputCallback(); + + (new LoggingCommitter($decorated, $config_factory, $time)) + ->commit($stagingDir, $activeDir, callback: $callback); + + $this->assertSame([ + "### Committing changes from staging-dir to active-dir\n", + "### Finished in 1.500 seconds\n", + ], $callback->getOutput()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php b/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php new file mode 100644 index 000000000000..f21577c348d3 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingStager; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * @covers \Drupal\package_manager\LoggingStager + * @group package_manager + */ +class LoggingStagerTest extends UnitTestCase { + + public function testDecoratedStagerIsCalled(): void { + $decorated = $this->createMock(StagerInterface::class); + + $activeDir = $this->createMock(PathInterface::class); + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + + $original_callback = $this->createMock(OutputCallbackInterface::class); + $original_callback->expects($this->once()) + ->method('__invoke') + ->with(OutputTypeEnum::OUT, "### Staging '--version' in staging-dir\n"); + + $decorated->expects($this->once()) + ->method('stage') + ->with( + ['--version'], + $activeDir, + $stagingDir, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $decorator = new LoggingStager($decorated, $config_factory); + $decorator->stage(['--version'], $activeDir, $stagingDir, callback: $original_callback); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php new file mode 100644 index 000000000000..abaa5ef6e7b8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\PathLocator + * @group package_manager + * @internal + */ +class PathLocatorTest extends UnitTestCase { + + /** + * @covers ::getStagingRoot + */ + public function testStagingRoot(): void { + $config_factory = $this->getConfigFactoryStub([ + 'system.site' => [ + 'uuid' => '_my_site_id', + ], + ]); + $file_system = $this->prophesize(FileSystemInterface::class); + $file_system->getTempDirectory()->willReturn('/path/to/temp'); + + $path_locator = new PathLocator( + '/path/to/drupal', + $config_factory, + $file_system->reveal() + ); + $this->assertSame('/path/to/temp/.package_manager_my_site_id', $path_locator->getStagingRoot()); + } + + /** + * Data provider for ::testWebRoot(). + * + * @return string[][] + * Sets of arguments to pass to the test method. + */ + public static function providerWebRoot(): array { + // In certain sites (like those created by drupal/recommended-project), the + // web root is a subdirectory of the project, and exists next to the + // vendor directory. + return [ + 'recommended project' => [ + '/path/to/project/www', + '/path/to/project', + 'www', + ], + 'recommended project with trailing slash on app root' => [ + '/path/to/project/www/', + '/path/to/project', + 'www', + ], + 'recommended project with trailing slash on project root' => [ + '/path/to/project/www', + '/path/to/project/', + 'www', + ], + 'recommended project with trailing slashes' => [ + '/path/to/project/www/', + '/path/to/project/', + 'www', + ], + // In legacy projects (i.e., created by drupal/legacy-project), the + // web root is the project root. + 'legacy project' => [ + '/path/to/drupal', + '/path/to/drupal', + '', + ], + 'legacy project with trailing slash on app root' => [ + '/path/to/drupal/', + '/path/to/drupal', + '', + ], + 'legacy project with trailing slash on project root' => [ + '/path/to/drupal', + '/path/to/drupal/', + '', + ], + 'legacy project with trailing slashes' => [ + '/path/to/drupal/', + '/path/to/drupal/', + '', + ], + ]; + } + + /** + * Tests that the web root is computed correctly. + * + * @param string $app_root + * The absolute path of the Drupal root. + * @param string $project_root + * The absolute path of the project root. + * @param string $expected_web_root + * The value expected from getWebRoot(). + * + * @covers ::getWebRoot + * + * @dataProvider providerWebRoot + */ + public function testWebRoot(string $app_root, string $project_root, string $expected_web_root): void { + $path_locator = $this->getMockBuilder(PathLocator::class) + // Mock all methods except getWebRoot(). + ->onlyMethods(['getProjectRoot', 'getStagingRoot', 'getVendorDirectory']) + ->setConstructorArgs([ + $app_root, + $this->getConfigFactoryStub(), + $this->prophesize(FileSystemInterface::class)->reveal(), + ]) + ->getMock(); + + $path_locator->method('getProjectRoot')->willReturn($project_root); + $this->assertSame($expected_web_root, $path_locator->getWebRoot()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php new file mode 100644 index 000000000000..b401fbd119cb --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * @covers \Drupal\package_manager\ProcessOutputCallback + * @group package_manager + */ +class ProcessOutputCallbackTest extends UnitTestCase { + + /** + * Tests what happens when the output buffer has invalid JSON. + */ + public function testInvalidJson(): void { + $callback = new ProcessOutputCallback(); + $callback(OutputTypeEnum::OUT, '{A string of invalid JSON! 😈'); + + $this->expectException(\JsonException::class); + $this->expectExceptionMessage('Syntax error'); + $callback->parseJsonOutput(); + } + + /** + * Tests what happens when there is error output only. + */ + public function testErrorOutputOnly(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + $error_text = 'What happened?'; + $callback(OutputTypeEnum::ERR, $error_text); + + $this->assertSame([$error_text], $callback->getErrorOutput()); + // The error should not yet be logged. + $this->assertEmpty($logger->records); + + // There should be no output data, but calling getOutput() should log the + // error. + $this->assertSame([], $callback->getOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertTrue($logger->hasWarning($error_text)); + + // Resetting the callback should clear the error buffer but the log should + // still have the error from before. + $callback->reset(); + $this->assertTrue($logger->hasWarning($error_text)); + } + + /** + * Tests the full lifecycle of a ProcessOutputCallback object. + */ + public function testCallback(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + // The buffers should initially be empty, and nothing should be logged. + $this->assertSame([], $callback->getOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertEmpty($logger->records); + + // Send valid JSON data to the callback, one line at a time. + $data = [ + 'value' => 'I have value!', + 'another value' => 'I have another value!', + 'one' => 1, + ]; + $json = json_encode($data, JSON_PRETTY_PRINT); + // Ensure the JSON is a multi-line string. + $this->assertGreaterThan(1, substr_count($json, "\n")); + $expected_output = []; + foreach (explode("\n", $json) as $line) { + $callback(OutputTypeEnum::OUT, "$line\n"); + $expected_output[] = "$line\n"; + } + $this->assertSame($expected_output, $callback->getOutput()); + // Ensure that parseJsonOutput() can parse the data without errors. + $this->assertSame($data, $callback->parseJsonOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertEmpty($logger->records); + + // If we send error output, it should be logged, but we should still be able + // to get the data we already sent. + $callback(OutputTypeEnum::ERR, 'Oh no, what happened?'); + $callback(OutputTypeEnum::ERR, 'Really what happened?!'); + $this->assertSame($data, $callback->parseJsonOutput()); + $expected_error = ['Oh no, what happened?', 'Really what happened?!']; + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Send more output and error data to the callback; they should be appended + // to the data we previously sent. + $callback(OutputTypeEnum::OUT, '{}'); + $expected_output[] = '{}'; + $callback(OutputTypeEnum::ERR, 'new Error 1!'); + $callback(OutputTypeEnum::ERR, 'new Error 2!'); + $expected_error[] = 'new Error 1!'; + $expected_error[] = 'new Error 2!'; + // The output buffer will no longer be valid JSON, so don't try to parse it. + $this->assertSame($expected_output, $callback->getOutput()); + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning(implode('', $expected_error))); + // The previously logged error output should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Clear all stored output and errors. + $callback->reset(); + $this->assertSame([], $callback->getOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + + // Send more output and error data. + $callback(OutputTypeEnum::OUT, 'Bonjour!'); + $callback(OutputTypeEnum::ERR, 'You continue to annoy me.'); + // We should now only see the stuff we just sent... + $this->assertSame(['Bonjour!'], $callback->getOutput()); + $this->assertSame(['You continue to annoy me.'], $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('You continue to annoy me.')); + // ...but the previously logged errors should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!new Error 1!new Error 2!')); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php new file mode 100644 index 000000000000..91d7acd321d6 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\package_manager\Event\RequireEventTrait + * @group package_manager + * @internal + */ +class RequireEventTraitTest extends UnitTestCase { + + /** + * Tests that runtime and dev packages are keyed correctly. + * + * @param string[] $runtime_packages + * The runtime package constraints passed to the event constructor. + * @param string[] $dev_packages + * The dev package constraints passed to the event constructor. + * @param string[] $expected_runtime_packages + * The keyed runtime packages that should be returned by + * ::getRuntimePackages(). + * @param string[] $expected_dev_packages + * The keyed dev packages that should be returned by ::getDevPackages(). + * + * @dataProvider providerGetPackages + */ + public function testGetPackages(array $runtime_packages, array $dev_packages, array $expected_runtime_packages, array $expected_dev_packages): void { + $stage = $this->createMock('\Drupal\package_manager\StageBase'); + + $events = [ + '\Drupal\package_manager\Event\PostRequireEvent', + '\Drupal\package_manager\Event\PreRequireEvent', + ]; + foreach ($events as $event) { + /** @var \Drupal\package_manager\Event\RequireEventTrait $event */ + $event = new $event($stage, $runtime_packages, $dev_packages); + $this->assertSame($expected_runtime_packages, $event->getRuntimePackages()); + $this->assertSame($expected_dev_packages, $event->getDevPackages()); + } + } + + /** + * Data provider for testGetPackages(). + * + * @return mixed[] + * The test cases. + */ + public static function providerGetPackages(): array { + return [ + 'Package with constraint' => [ + ['drupal/new_package:^8.1'], + ['drupal/dev_package:^9'], + ['drupal/new_package' => '^8.1'], + ['drupal/dev_package' => '^9'], + ], + 'Package without constraint' => [ + ['drupal/new_package'], + ['drupal/dev_package'], + ['drupal/new_package' => '*'], + ['drupal/dev_package' => '*'], + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/StageBaseTest.php b/core/modules/package_manager/tests/src/Unit/StageBaseTest.php new file mode 100644 index 000000000000..456a0e83e07b --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/StageBaseTest.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\StageBase; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\StageBase + * @group package_manager + * @internal + */ +class StageBaseTest extends UnitTestCase { + + /** + * @covers ::validateRequirements + * + * @param string|null $expected_exception + * The exception class that should be thrown, or NULL if there should not be + * any exception. + * @param string $requirement + * The requirement (package name and optional constraint) to validate. + * + * @dataProvider providerValidateRequirements + */ + public function testValidateRequirements(?string $expected_exception, string $requirement): void { + $reflector = new \ReflectionClass(StageBase::class); + $method = $reflector->getMethod('validateRequirements'); + + if ($expected_exception) { + $this->expectException($expected_exception); + } + else { + $this->assertNull($expected_exception); + } + + $method->invoke(NULL, [$requirement]); + } + + /** + * Data provider for testValidateRequirements. + * + * @return array[] + * The test cases. + */ + public static function providerValidateRequirements(): array { + return [ + // Valid requirements. + [NULL, 'vendor/package'], + [NULL, 'vendor/snake_case'], + [NULL, 'vendor/kebab-case'], + [NULL, 'vendor/with.dots'], + [NULL, '1vendor2/3package4'], + [NULL, 'vendor/package:1'], + [NULL, 'vendor/package:1.2'], + [NULL, 'vendor/package:1.2.3'], + [NULL, 'vendor/package:1.x'], + [NULL, 'vendor/package:^1'], + [NULL, 'vendor/package:~1'], + [NULL, 'vendor/package:>1'], + [NULL, 'vendor/package:<1'], + [NULL, 'vendor/package:>=1'], + [NULL, 'vendor/package:>1 <2'], + [NULL, 'vendor/package:1 || 2'], + [NULL, 'vendor/package:>=1,<1.1.0'], + [NULL, 'vendor/package:1a'], + [NULL, 'vendor/package:*'], + [NULL, 'vendor/package:dev-master'], + [NULL, 'vendor/package:*@dev'], + [NULL, 'vendor/package:@dev'], + [NULL, 'vendor/package:master@dev'], + [NULL, 'vendor/package:master@beta'], + [NULL, 'php'], + [NULL, 'php:8'], + [NULL, 'php:8.0'], + [NULL, 'php:^8.1'], + [NULL, 'php:~8.1'], + [NULL, 'php-64bit'], + [NULL, 'composer'], + [NULL, 'composer-plugin-api'], + [NULL, 'composer-plugin-api:1'], + [NULL, 'ext-json'], + [NULL, 'ext-json:1'], + [NULL, 'ext-pdo_mysql'], + [NULL, 'ext-pdo_mysql:1'], + [NULL, 'lib-curl'], + [NULL, 'lib-curl:1'], + [NULL, 'lib-curl-zlib'], + [NULL, 'lib-curl-zlib:1'], + + // Invalid requirements. + [\InvalidArgumentException::class, ''], + [\InvalidArgumentException::class, ' '], + [\InvalidArgumentException::class, '/'], + [\InvalidArgumentException::class, 'php8'], + [\InvalidArgumentException::class, 'package'], + [\InvalidArgumentException::class, 'vendor\package'], + [\InvalidArgumentException::class, 'vendor//package'], + [\InvalidArgumentException::class, 'vendor/package1 vendor/package2'], + [\InvalidArgumentException::class, 'vendor/package/extra'], + [\UnexpectedValueException::class, 'vendor/package:a'], + [\UnexpectedValueException::class, 'vendor/package:'], + [\UnexpectedValueException::class, 'vendor/package::'], + [\UnexpectedValueException::class, 'vendor/package::1'], + [\UnexpectedValueException::class, 'vendor/package:1:2'], + [\UnexpectedValueException::class, 'vendor/package:develop@dev@dev'], + [\UnexpectedValueException::class, 'vendor/package:develop@'], + [\InvalidArgumentException::class, 'vEnDor/pAcKaGe'], + [\InvalidArgumentException::class, '_vendor/package'], + [\InvalidArgumentException::class, '_vendor/_package'], + [\InvalidArgumentException::class, 'vendor_/package'], + [\InvalidArgumentException::class, '_vendor/package_'], + [\InvalidArgumentException::class, 'vendor/package-'], + [\InvalidArgumentException::class, 'php-'], + [\InvalidArgumentException::class, 'ext'], + [\InvalidArgumentException::class, 'lib'], + ]; + } + + /** + * @covers ::getType + */ + public function testTypeMustBeExplicitlyOverridden(): void { + $good_grandchild = new class () extends ChildStage { + + /** + * {@inheritdoc} + */ + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + protected string $type = 'package_manager:good_grandchild'; + + }; + $this->assertSame('package_manager:good_grandchild', $good_grandchild->getType()); + + $bad_grandchild = new class () extends ChildStage {}; + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(get_class($bad_grandchild) . ' must explicitly override the $type property.'); + $bad_grandchild->getType(); + } + +} + +class ChildStage extends StageBase { + + public function __construct() {} + + protected string $type = 'package_manager:child'; + +} diff --git a/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php b/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php new file mode 100644 index 000000000000..1506d5450b84 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\StageNotInActiveValidator; +use Drupal\Tests\package_manager\Traits\ValidationTestTrait; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use Symfony\Component\Filesystem\Path; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\StageNotInActiveValidator + * @group package_manager + * @internal + */ +class StageNotInActiveValidatorTest extends UnitTestCase { + use ValidationTestTrait; + + /** + * @covers ::validate + * + * @param \Drupal\package_manager\ValidationResult[] $expected + * The expected result. + * @param string $project_root + * The project root. + * @param string $staging_root + * The staging root. + * + * @dataProvider providerTestCheckNotInActive + */ + public function testCheckNotInActive(array $expected, string $project_root, string $staging_root): void { + $path_locator_prophecy = $this->prophesize(PathLocator::class); + $path_locator_prophecy->getProjectRoot()->willReturn(Path::canonicalize($project_root)); + $path_locator_prophecy->getStagingRoot()->willReturn(Path::canonicalize($staging_root)); + $path_locator_prophecy->getVendorDirectory()->willReturn('not used'); + $path_locator = $path_locator_prophecy->reveal(); + $stage = $this->prophesize(StageBase::class)->reveal(); + + $stage_not_in_active_validator = new StageNotInActiveValidator($path_locator); + $stage_not_in_active_validator->setStringTranslation($this->getStringTranslationStub()); + $event = new PreCreateEvent($stage, $this->createMock(PathListInterface::class)); + $stage_not_in_active_validator->validate($event); + $this->assertValidationResultsEqual($expected, $event->getResults(), $path_locator); + } + + /** + * Data provider for testCheckNotInActive(). + * + * @return mixed[] + * The test cases. + */ + public static function providerTestCheckNotInActive(): array { + $expected_symlink_validation_error = ValidationResult::createError([ + t('Stage directory is a subdirectory of the active directory.'), + ]); + + return [ + "Absolute paths which don't satisfy" => [ + [$expected_symlink_validation_error], + "/var/root", + "/var/root/xyz", + ], + "Absolute paths which satisfy" => [ + [], + "/var/root", + "/home/var/root", + ], + 'Stage with .. segments, outside active' => [ + [], + "/var/root/active", + "/var/root/active/../stage", + ], + 'Stage without .. segments, outside active' => [ + [], + "/var/root/active", + "/var/root/stage", + ], + 'Stage with .. segments, inside active' => [ + [$expected_symlink_validation_error], + "/var/root/active", + "/var/root/active/../active/stage", + ], + 'Stage without .. segments, inside active' => [ + [$expected_symlink_validation_error], + "/var/root/active", + "/var/root/active/stage", + ], + 'Stage with .. segments, outside active, active with .. segments' => [ + [], + "/var/root/active", + "/var/root/active/../stage", + ], + 'Stage without .. segments, outside active, active with .. segments' => [ + [], + "/var/root/random/../active", + "/var/root/stage", + ], + 'Stage with .. segments, inside active, active with .. segments' => [ + [$expected_symlink_validation_error], + "/var/root/random/../active", + "/var/root/active/../active/stage", + ], + 'Stage without .. segments, inside active, active with .. segments' => [ + [$expected_symlink_validation_error], + "/var/root/random/../active", + "/var/root/active/stage", + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php new file mode 100644 index 000000000000..5d87a5d1558f --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php @@ -0,0 +1,175 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\ValidationResult; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\ValidationResult + * @group package_manager + * @internal + */ +class ValidationResultTest extends UnitTestCase { + + /** + * @covers ::createWarning + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateWarningResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createWarning($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING); + } + + /** + * @covers ::getOverallSeverity + */ + public function testOverallSeverity(): void { + // An error and a warning should be counted as an error. + $results = [ + ValidationResult::createError([t('Boo!')]), + ValidationResult::createWarning([t('Moo!')]), + ]; + $this->assertSame(SystemManager::REQUIREMENT_ERROR, ValidationResult::getOverallSeverity($results)); + + // If there are no results, but no errors, the results should be counted as + // a warning. + array_shift($results); + $this->assertSame(SystemManager::REQUIREMENT_WARNING, ValidationResult::getOverallSeverity($results)); + + // If there are just plain no results, we should get REQUIREMENT_OK. + array_shift($results); + $this->assertSame(SystemManager::REQUIREMENT_OK, ValidationResult::getOverallSeverity($results)); + } + + /** + * @covers ::createError + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateErrorResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createError($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR); + } + + /** + * @covers ::createWarning + * + * @param string[] $messages + * The warning messages of the validation result. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider providerCreateExceptions + */ + public function testCreateWarningResultException(array $messages, string $expected_exception_message): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected_exception_message); + ValidationResult::createWarning($messages, NULL); + } + + /** + * @covers ::createError + * + * @param string[] $messages + * The error messages of the validation result. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider providerCreateExceptions + */ + public function testCreateErrorResultException(array $messages, string $expected_exception_message): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected_exception_message); + ValidationResult::createError($messages, NULL); + } + + /** + * Tests that the messages are asserted to be translatable. + * + * @testWith ["createError"] + * ["createWarning"] + */ + public function testMessagesMustBeTranslatable(string $method): void { + // When creating an error from a throwable, the message does not need to be + // translatable. + ValidationResult::createErrorFromThrowable(new \Exception('Burn it down.')); + + $this->expectException(\AssertionError::class); + $this->expectExceptionMessageMatches('/instanceof TranslatableMarkup/'); + ValidationResult::$method(['Not translatable!']); + } + + /** + * Data provider for test methods that test create exceptions. + * + * @return array[] + * The test cases. + */ + public static function providerCreateExceptions(): array { + return [ + '2 messages, no summary' => [ + [t('Something is wrong'), t('Something else is also wrong')], + 'If more than one message is provided, a summary is required.', + ], + 'no messages' => [ + [], + 'At least one message is required.', + ], + ]; + } + + /** + * Data provider for testCreateWarningResult(). + * + * @return mixed[] + * The test cases. + */ + public static function providerValidConstructorArguments(): array { + return [ + '1 message no summary' => [ + 'messages' => [t('Something is wrong')], + 'summary' => NULL, + ], + '2 messages has summary' => [ + 'messages' => [ + t('Something is wrong'), + t('Something else is also wrong'), + ], + 'summary' => 'This sums it up.', + ], + ]; + } + + /** + * Asserts a check result is valid. + * + * @param \Drupal\package_manager\ValidationResult $result + * The validation result to check. + * @param array $expected_messages + * The expected messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The expected summary or NULL if not summary is expected. + * @param int $severity + * The severity. + */ + protected function assertResultValid(ValidationResult $result, array $expected_messages, ?TranslatableMarkup $summary, int $severity): void { + $this->assertSame($expected_messages, $result->messages); + if ($summary === NULL) { + $this->assertNull($result->summary); + } + else { + $this->assertSame($summary->getUntranslatedString(), $result->summary + ->getUntranslatedString()); + } + $this->assertSame($severity, $result->severity); + } + +} diff --git a/core/scripts/PackageManagerFixtureCreator.php b/core/scripts/PackageManagerFixtureCreator.php new file mode 100644 index 000000000000..b518424de797 --- /dev/null +++ b/core/scripts/PackageManagerFixtureCreator.php @@ -0,0 +1,113 @@ +#!/usr/bin/env php +<?php + +/** + * @file + * A script that updates the package_manager test 'fake_site' fixture. + */ + +declare(strict_types=1); + +use Composer\Json\JsonFile; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Process; + +if (PHP_SAPI !== 'cli') { + return; +} +// Bootstrap. +require __DIR__ . '/../../autoload.php'; + +PackageManagerFixtureCreator::createFixture(); +/** + * Creates fixture at 'core/modules/package_manager/tests/fixtures/fake_site'. + */ +final class PackageManagerFixtureCreator { + + private const FIXTURE_PATH = __DIR__ . '/../modules/package_manager/tests/fixtures/fake_site'; + + private const CORE_ROOT_PATH = __DIR__ . '/../..'; + + /** + * Creates the fixture. + */ + public static function createFixture(): void { + // Copy drupal scaffold file mapping from core/composer.json to + // fixtures' core/composer.json. + $core_composer_json = new JsonFile(static::CORE_ROOT_PATH . '/core/composer.json'); + $core_composer_data = $core_composer_json->read(); + $fixture_core_composer_file = new JsonFile(static::FIXTURE_PATH . "/../path_repos/drupal--core/composer.json"); + $fixture_core_composer_data = $fixture_core_composer_file->read(); + $fixture_core_composer_data['extra']['drupal-scaffold']['file-mapping'] = $core_composer_data['extra']['drupal-scaffold']['file-mapping']; + $fixture_core_composer_file->write($fixture_core_composer_data); + + $fixture_packages_json = new JsonFile(static::FIXTURE_PATH . '/packages.json'); + $fixture_packages_data = $fixture_packages_json->read(); + foreach ($fixture_packages_data['packages']['drupal/core'] as &$release) { + $release['extra']['drupal-scaffold']['file-mapping'] = $core_composer_data['extra']['drupal-scaffold']['file-mapping']; + } + $fixture_packages_json->write($fixture_packages_data); + + $fs = new Filesystem(); + $fs->remove(static::FIXTURE_PATH . "/composer.lock"); + // Remove all the vendor folders but leave our 2 test files. + // @see \Drupal\Tests\package_manager\Kernel\PathExcluder\VendorHardeningExcluderTest + self::removeAllExcept(static::FIXTURE_PATH . "/vendor", ['.htaccess', 'web.config']); + + self::runComposerCommand(['install']); + static::removeAllExcept(static::FIXTURE_PATH . '/vendor/composer', ['installed.json', 'installed.php']); + $fs->remove(static::FIXTURE_PATH . '/vendor/autoload.php'); + print "\nFixture updated.\nRunning phpcbf"; + + $process = new Process(['composer', 'phpcbf', static::FIXTURE_PATH], static::CORE_ROOT_PATH); + $process->run(); + print "\nFixture created 🎉."; + } + + /** + * Runs a Composer command at the fixture root. + * + * @param array $command + * The command to run as passed to + * \Symfony\Component\Process\Process::__construct. + * + * @return string + * The Composer command output. + */ + private static function runComposerCommand(array $command): string { + array_unshift($command, 'composer'); + $command[] = "--working-dir=" . static::FIXTURE_PATH; + $process = new Process($command, env: [ + 'COMPOSER_MIRROR_PATH_REPOS' => '1', + ]); + $process->run(); + if (!$process->isSuccessful()) { + throw new ProcessFailedException($process); + } + return $process->getOutput(); + } + + /** + * Removes all files in a directory except the ones specified. + * + * @param string $directory + * The directory path. + * @param string[] $files_to_keep + * The files to not delete. + */ + private static function removeAllExcept(string $directory, array $files_to_keep): void { + if (!is_dir($directory)) { + throw new \LogicException("Expected directory $directory"); + } + $paths_to_remove = glob("$directory/*"); + $fs = new Filesystem(); + foreach ($paths_to_remove as $path_to_remove) { + $base_name = basename($path_to_remove); + if (!in_array($base_name, $files_to_keep, TRUE)) { + $fs->remove($path_to_remove); + } + } + } + +} diff --git a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php index 2a4c41be1c40..68a1d9a83bfa 100644 --- a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php @@ -32,7 +32,7 @@ class ComposerProjectTemplatesTest extends ComposerBuildTestBase { * * @see https://getcomposer.org/doc/04-schema.md#minimum-stability */ - protected const MINIMUM_STABILITY = 'stable'; + protected const MINIMUM_STABILITY = 'RC'; /** * The order of stability strings from least stable to most stable. -- GitLab