From 3d81e627d52edaf9ac1e740423ccffe27315d10c Mon Sep 17 00:00:00 2001 From: Ted Bowman <ted+git@tedbow.com> Date: Wed, 6 Mar 2024 17:06:51 -0500 Subject: [PATCH] Contrib: Issue #3424290 by abhishek_gupta1: Error in CollectPathsToExcludeEvent.php -> must be string, bool given - https://git.drupalcode.org/project/automatic_updates/-/commit/10c0945e166c395258169f718ecc63eec9f00333 --- composer.json | 1 + composer.lock | 173 +++- .../Metapackage/CoreRecommended/composer.json | 3 +- .../Metapackage/DevDependencies/composer.json | 1 + .../PinnedDevDependencies/composer.json | 1 + core/composer.json | 3 +- core/lib/Drupal/Component/Diff/composer.json | 2 +- core/misc/cspell/dictionary.txt | 14 + core/modules/auto_updates/.gitlab-ci.yml | 241 +++++ .../auto_updates/auto_updates.info.yml | 9 + .../modules/auto_updates/auto_updates.install | 43 + .../auto_updates/auto_updates.libraries.yml | 4 + core/modules/auto_updates/auto_updates.module | 311 +++++++ .../auto_updates/auto_updates.post_update.php | 19 + .../auto_updates/auto_updates.routing.yml | 29 + .../auto_updates/auto_updates.services.yml | 58 ++ .../config/install/auto_updates.settings.yml | 5 + .../config/schema/auto_updates.schema.yml | 29 + .../auto_updates/css/update-status.css | 8 + core/modules/auto_updates/phpstan.neon | 9 + .../src/AutoUpdatesServiceProvider.php | 34 + .../auto_updates/src/BatchProcessor.php | 281 ++++++ .../auto_updates/src/CommandExecutor.php | 102 ++ .../src/Commands/AutoUpdatesCommandBase.php | 140 +++ .../src/Commands/PostApplyCommand.php | 55 ++ .../auto_updates/src/Commands/RunCommand.php | 107 +++ .../auto_updates/src/ConsoleUpdateStage.php | 342 +++++++ .../src/Controller/StatusCheckController.php | 79 ++ .../src/Controller/UpdateController.php | 99 ++ .../auto_updates/src/CronUpdateRunner.php | 156 ++++ .../auto_updates/src/Form/UpdateFormBase.php | 74 ++ .../auto_updates/src/Form/UpdateReady.php | 206 +++++ .../auto_updates/src/Form/UpdaterForm.php | 458 +++++++++ .../src/MaintenanceModeAwareCommitter.php | 102 ++ .../auto_updates/src/ReleaseChooser.php | 139 +++ .../src/Routing/RouteSubscriber.php | 85 ++ .../auto_updates/src/StatusCheckMailer.php | 176 ++++ core/modules/auto_updates/src/UpdateStage.php | 163 ++++ .../Validation/AdminStatusCheckMessages.php | 224 +++++ .../Validation/StatusCheckRequirements.php | 197 ++++ .../src/Validation/StatusChecker.php | 187 ++++ .../ValidationResultDisplayTrait.php | 41 + .../src/Validator/CronFrequencyValidator.php | 122 +++ .../src/Validator/PhpExtensionsValidator.php | 46 + .../Validator/RequestedUpdateValidator.php | 105 +++ .../StagedDatabaseUpdateValidator.php | 61 ++ .../src/Validator/StagedProjectsValidator.php | 143 +++ .../VersionPolicy/ForbidDevSnapshot.php | 45 + .../VersionPolicy/ForbidDowngrade.php | 47 + .../VersionPolicy/ForbidMinorUpdates.php | 49 + .../VersionPolicy/MajorVersionMatch.php | 50 + .../VersionPolicy/StableReleaseInstalled.php | 46 + .../SupportedBranchInstalled.php | 112 +++ .../VersionPolicy/TargetSecurityRelease.php | 46 + .../TargetVersionInstallable.php | 93 ++ .../TargetVersionNotPreRelease.php | 44 + .../VersionPolicy/TargetVersionStable.php | 44 + .../src/Validator/VersionPolicyValidator.php | 327 +++++++ .../src/Validator/WindowsValidator.php | 83 ++ .../auto_updates/src/VersionParsingTrait.php | 54 ++ .../auto_updates_test.info.yml | 7 + .../auto_updates_test.install | 17 + .../auto_updates_test.module | 29 + .../auto_updates_test.services.yml | 15 + .../src/Datetime/TestTime.php | 56 ++ .../EventSubscriber/RequestTimeRecorder.php | 67 ++ .../src/EventSubscriber/TestSubscriber1.php | 13 + .../auto_updates_test_api.info.yml | 6 + .../auto_updates_test_api.routing.yml | 23 + .../src/ApiController.php | 52 ++ .../auto_updates_test_status_checker.info.yml | 6 + ...o_updates_test_status_checker.services.yml | 6 + .../src/EventSubscriber/TestSubscriber2.php | 25 + .../tests/src/Build/CoreUpdateTest.php | 481 ++++++++++ .../tests/src/Build/UpdateTestBase.php | 107 +++ .../AutoUpdatesFunctionalTestBase.php | 125 +++ .../Functional/AvailableUpdatesReportTest.php | 91 ++ .../src/Functional/ClickableHelpTest.php | 61 ++ .../ComposerStagerOperationFailureTest.php | 92 ++ .../Functional/DeleteExistingUpdateTest.php | 132 +++ .../ErrorMessageOnStageDestroyTest.php | 67 ++ .../tests/src/Functional/GenericTest.php | 14 + .../tests/src/Functional/HelpPageTest.php | 47 + .../src/Functional/NoUpdateButtonsTest.php | 70 ++ .../tests/src/Functional/PreUpdateTest.php | 64 ++ .../Functional/StagedDatabaseUpdateTest.php | 127 +++ .../StatusCheckFailureEmailTest.php | 234 +++++ .../tests/src/Functional/StatusCheckTest.php | 760 +++++++++++++++ .../StatusCheckerRunAfterUpdateTest.php | 93 ++ .../src/Functional/SuccessfulUpdateTest.php | 110 +++ .../src/Functional/TableLooksCorrectTest.php | 172 ++++ .../Functional/UpdateCompleteMessageTest.php | 59 ++ .../tests/src/Functional/UpdateErrorTest.php | 281 ++++++ .../tests/src/Functional/UpdateFailedTest.php | 89 ++ .../tests/src/Functional/UpdateLockTest.php | 83 ++ .../src/Functional/UpdateWarningTest.php | 54 ++ ...terFormNoRecommendedReleaseMessageTest.php | 103 +++ .../src/Functional/UpdaterFormTestBase.php | 147 +++ .../FunctionalJavascript/UpdateErrorTest.php | 99 ++ .../UpdateSettingsFormTest.php | 62 ++ .../src/Kernel/AutoUpdatesKernelTestBase.php | 178 ++++ .../src/Kernel/ConsoleUpdateStageTest.php | 731 +++++++++++++++ .../tests/src/Kernel/CronUpdateRunnerTest.php | 96 ++ .../tests/src/Kernel/ReleaseChooserTest.php | 202 ++++ .../CronFrequencyValidatorTest.php | 131 +++ .../PhpExtensionsValidatorTest.php | 102 ++ .../RequestedUpdateValidatorTest.php | 91 ++ .../StagedDatabaseUpdateValidatorTest.php | 44 + .../StagedProjectsValidatorTest.php | 328 +++++++ .../Kernel/StatusCheck/StatusCheckerTest.php | 303 ++++++ .../SupportedBranchInstalledTest.php | 95 ++ .../VersionPolicyValidatorTest.php | 731 +++++++++++++++ .../StatusCheck/WindowsValidatorTest.php | 155 ++++ .../tests/src/Kernel/UpdateStageTest.php | 210 +++++ .../Traits/EmailNotificationsTestTrait.php | 96 ++ .../tests/src/Traits/TestSetUpTrait.php | 95 ++ .../tests/src/Traits/ValidationTestTrait.php | 103 +++ .../src/Traits/VersionPolicyTestTrait.php | 36 + .../src/Unit/LegacyVersionUtilityTest.php | 77 ++ .../VersionPolicy/ForbidDevSnapshotTest.php | 66 ++ .../VersionPolicy/ForbidDowngradeTest.php | 68 ++ .../VersionPolicy/ForbidMinorUpdatesTest.php | 94 ++ .../VersionPolicy/MajorVersionMatchTest.php | 83 ++ .../StableReleaseInstalledTest.php | 62 ++ .../TargetSecurityReleaseTest.php | 70 ++ .../TargetVersionInstallableTest.php | 201 ++++ .../VersionPolicy/TargetVersionStableTest.php | 66 ++ .../auto_updates_theme.info.yml | 5 + .../auto_updates_theme_with_updates.info.yml | 5 + .../auto_updates_theme_with_updates.install | 8 + ...updates_theme_with_updates.post_update.php | 8 + .../install/package_manager.settings.yml | 8 + .../config/schema/package_manager.schema.yml | 30 + .../package_manager/package_manager.api.php | 291 ++++++ .../package_manager/package_manager.info.yml | 8 + .../package_manager/package_manager.install | 71 ++ .../package_manager/package_manager.module | 112 +++ .../package_manager.services.yml | 178 ++++ .../package_manager/src/ComposerInspector.php | 496 ++++++++++ .../src/Event/CollectPathsToExcludeEvent.php | 129 +++ .../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 | 77 ++ .../src/EventSubscriber/ChangeLogger.php | 191 ++++ .../EventSubscriber/UpdateDataSubscriber.php | 51 + .../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 | 43 + .../package_manager/src/FailureMarker.php | 169 ++++ .../package_manager/src/FileSyncerFactory.php | 61 ++ .../package_manager/src/ImmutablePathList.php | 41 + .../package_manager/src/InstalledPackage.php | 111 +++ .../src/InstalledPackagesList.php | 184 ++++ .../src/LegacyVersionUtility.php | 86 ++ .../src/NoSymlinksPointToADirectory.php | 106 +++ .../src/PackageManagerServiceProvider.php | 92 ++ .../src/PackageManagerUninstallValidator.php | 91 ++ .../src/PackageManagerUpdateProcessor.php | 100 ++ .../src/PathExcluder/GitExcluder.php | 88 ++ .../src/PathExcluder/NodeModulesExcluder.php | 39 + .../SiteConfigurationExcluder.php | 154 +++ .../src/PathExcluder/SiteFilesExcluder.php | 73 ++ .../PathExcluder/SqliteDatabaseExcluder.php | 75 ++ .../src/PathExcluder/TestSiteExcluder.php | 41 + .../src/PathExcluder/UnknownPathExcluder.php | 189 ++++ .../PathExcluder/VendorHardeningExcluder.php | 55 ++ .../package_manager/src/PathLocator.php | 106 +++ .../src/Plugin/QueueWorker/Cleaner.php | 62 ++ .../package_manager/src/ProcessFactory.php | 121 +++ .../src/ProcessOutputCallback.php | 112 +++ .../package_manager/src/ProjectInfo.php | 236 +++++ .../PackageManagerUninstallValidator.php | 88 ++ .../modules/package_manager/src/StageBase.php | 875 ++++++++++++++++++ .../package_manager/src/StatusCheckTrait.php | 53 ++ .../src/TranslatableStringAdapter.php | 53 ++ .../src/TranslatableStringFactory.php | 64 ++ .../package_manager/src/ValidationResult.php | 150 +++ .../AllowedScaffoldPackagesValidator.php | 80 ++ .../BaseRequirementValidatorTrait.php | 50 + .../BaseRequirementsFulfilledValidator.php | 71 ++ .../ComposerMinimumStabilityValidator.php | 90 ++ .../Validator/ComposerPatchesValidator.php | 192 ++++ .../Validator/ComposerPluginsValidator.php | 241 +++++ .../src/Validator/ComposerValidator.php | 156 ++++ .../src/Validator/DiskSpaceValidator.php | 153 +++ .../Validator/DuplicateInfoFileValidator.php | 120 +++ .../Validator/EnabledExtensionsValidator.php | 88 ++ .../Validator/EnvironmentSupportValidator.php | 75 ++ .../src/Validator/LockFileValidator.php | 183 ++++ .../src/Validator/MultisiteValidator.php | 69 ++ .../OverwriteExistingPackagesValidator.php | 88 ++ .../src/Validator/PendingUpdatesValidator.php | 82 ++ .../src/Validator/PhpExtensionsValidator.php | 108 +++ .../src/Validator/PhpTufValidator.php | 184 ++++ .../src/Validator/RsyncValidator.php | 116 +++ .../src/Validator/SettingsValidator.php | 49 + .../Validator/StageNotInActiveValidator.php | 60 ++ .../src/Validator/StagedDBUpdateValidator.php | 210 +++++ .../Validator/SupportedReleaseValidator.php | 142 +++ .../src/Validator/SymlinkValidator.php | 91 ++ .../Validator/WritableFileSystemValidator.php | 98 ++ .../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 | 43 + .../tests/fixtures/fake_site/composer.lock | 88 ++ .../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 | 615 ++++++++++++ .../src/ProcessFactory.php | 41 + .../src/StageFixtureManipulator.php | 109 +++ .../package_manager_bypass.info.yml | 7 + .../package_manager_bypass.services.yml | 7 + .../src/ComposerStagerExceptionTrait.php | 33 + .../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 | 179 ++++ ...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 | 179 ++++ ...geManagerTestValidationServiceProvider.php | 33 + .../src/StagedDatabaseUpdateValidator.php | 55 ++ .../tests/src/Build/PackageInstallTest.php | 90 ++ .../tests/src/Build/PackageUpdateTest.php | 74 ++ .../src/Build/TemplateProjectTestBase.php | 678 ++++++++++++++ .../Functional/ComposerRequirementTest.php | 59 ++ .../FailureMarkerRequirementTest.php | 73 ++ .../tests/src/Functional/GenericTest.php | 14 + .../AllowedScaffoldPackagesValidatorTest.php | 61 ++ ...BaseRequirementsFulfilledValidatorTest.php | 93 ++ .../tests/src/Kernel/ChangeLoggerTest.php | 102 ++ .../src/Kernel/ComposerInspectorTest.php | 552 +++++++++++ .../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/ExecutableFinderTest.php | 50 + .../tests/src/Kernel/FailureMarkerTest.php | 100 ++ .../tests/src/Kernel/FakeSiteFixtureTest.php | 161 ++++ .../src/Kernel/FileSyncerFactoryTest.php | 69 ++ .../src/Kernel/FixtureManipulatorTest.php | 280 ++++++ .../src/Kernel/InstalledPackagesListTest.php | 168 ++++ .../src/Kernel/LockFileValidatorTest.php | 215 +++++ .../src/Kernel/MultisiteValidatorTest.php | 107 +++ ...OverwriteExistingPackagesValidatorTest.php | 159 ++++ .../Kernel/PackageManagerKernelTestBase.php | 524 +++++++++++ .../Kernel/PathExcluder/GitExcluderTest.php | 140 +++ .../PathExcluder/NodeModulesExcluderTest.php | 55 ++ .../SiteConfigurationExcluderTest.php | 123 +++ .../PathExcluder/SiteFilesExcluderTest.php | 72 ++ .../SqliteDatabaseExcluderTest.php | 150 +++ .../PathExcluder/TestSiteExcluderTest.php | 49 + .../PathExcluder/UnknownPathExcluderTest.php | 230 +++++ .../VendorHardeningExcluderTest.php | 50 + .../Kernel/PendingUpdatesValidatorTest.php | 89 ++ .../src/Kernel/PhpExtensionsValidatorTest.php | 87 ++ .../tests/src/Kernel/PhpTufValidatorTest.php | 251 +++++ .../tests/src/Kernel/ProcessFactoryTest.php | 37 + .../tests/src/Kernel/ProjectInfoTest.php | 303 ++++++ .../tests/src/Kernel/RsyncValidatorTest.php | 142 +++ .../tests/src/Kernel/ServicesTest.php | 50 + .../src/Kernel/SettingsValidatorTest.php | 66 ++ .../tests/src/Kernel/StageBaseTest.php | 833 +++++++++++++++++ .../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 | 219 +++++ .../tests/src/Kernel/SymlinkValidatorTest.php | 239 +++++ .../src/Kernel/TranslatableStringTest.php | 41 + .../WritableFileSystemValidatorTest.php | 265 ++++++ .../src/Traits/AssertPreconditionsTrait.php | 109 +++ .../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/InstalledPackageTest.php | 67 ++ .../src/Unit/InstalledPackagesListTest.php | 165 ++++ .../tests/src/Unit/PathLocatorTest.php | 123 +++ .../src/Unit/ProcessOutputCallbackTest.php | 128 +++ .../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 +++ core/scripts/auto-update | 46 + .../Template/ComposerProjectTemplatesTest.php | 5 +- 388 files changed, 39581 insertions(+), 12 deletions(-) create mode 100644 core/modules/auto_updates/.gitlab-ci.yml create mode 100644 core/modules/auto_updates/auto_updates.info.yml create mode 100644 core/modules/auto_updates/auto_updates.install create mode 100644 core/modules/auto_updates/auto_updates.libraries.yml create mode 100644 core/modules/auto_updates/auto_updates.module create mode 100644 core/modules/auto_updates/auto_updates.post_update.php create mode 100644 core/modules/auto_updates/auto_updates.routing.yml create mode 100644 core/modules/auto_updates/auto_updates.services.yml create mode 100644 core/modules/auto_updates/config/install/auto_updates.settings.yml create mode 100644 core/modules/auto_updates/config/schema/auto_updates.schema.yml create mode 100644 core/modules/auto_updates/css/update-status.css create mode 100644 core/modules/auto_updates/phpstan.neon create mode 100644 core/modules/auto_updates/src/AutoUpdatesServiceProvider.php create mode 100644 core/modules/auto_updates/src/BatchProcessor.php create mode 100644 core/modules/auto_updates/src/CommandExecutor.php create mode 100644 core/modules/auto_updates/src/Commands/AutoUpdatesCommandBase.php create mode 100644 core/modules/auto_updates/src/Commands/PostApplyCommand.php create mode 100644 core/modules/auto_updates/src/Commands/RunCommand.php create mode 100644 core/modules/auto_updates/src/ConsoleUpdateStage.php create mode 100644 core/modules/auto_updates/src/Controller/StatusCheckController.php create mode 100644 core/modules/auto_updates/src/Controller/UpdateController.php create mode 100644 core/modules/auto_updates/src/CronUpdateRunner.php create mode 100644 core/modules/auto_updates/src/Form/UpdateFormBase.php create mode 100644 core/modules/auto_updates/src/Form/UpdateReady.php create mode 100644 core/modules/auto_updates/src/Form/UpdaterForm.php create mode 100644 core/modules/auto_updates/src/MaintenanceModeAwareCommitter.php create mode 100644 core/modules/auto_updates/src/ReleaseChooser.php create mode 100644 core/modules/auto_updates/src/Routing/RouteSubscriber.php create mode 100644 core/modules/auto_updates/src/StatusCheckMailer.php create mode 100644 core/modules/auto_updates/src/UpdateStage.php create mode 100644 core/modules/auto_updates/src/Validation/AdminStatusCheckMessages.php create mode 100644 core/modules/auto_updates/src/Validation/StatusCheckRequirements.php create mode 100644 core/modules/auto_updates/src/Validation/StatusChecker.php create mode 100644 core/modules/auto_updates/src/Validation/ValidationResultDisplayTrait.php create mode 100644 core/modules/auto_updates/src/Validator/CronFrequencyValidator.php create mode 100644 core/modules/auto_updates/src/Validator/PhpExtensionsValidator.php create mode 100644 core/modules/auto_updates/src/Validator/RequestedUpdateValidator.php create mode 100644 core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php create mode 100644 core/modules/auto_updates/src/Validator/StagedProjectsValidator.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionNotPreRelease.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicyValidator.php create mode 100644 core/modules/auto_updates/src/Validator/WindowsValidator.php create mode 100644 core/modules/auto_updates/src/VersionParsingTrait.php create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/TestSubscriber1.php create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.info.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.routing.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_api/src/ApiController.php create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.info.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.services.yml create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/src/EventSubscriber/TestSubscriber2.php create mode 100644 core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Build/UpdateTestBase.php create mode 100644 core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php create mode 100644 core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/ClickableHelpTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/ComposerStagerOperationFailureTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/DeleteExistingUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/ErrorMessageOnStageDestroyTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/GenericTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/HelpPageTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/NoUpdateButtonsTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/PreUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/StagedDatabaseUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/StatusCheckFailureEmailTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/StatusCheckTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/StatusCheckerRunAfterUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/SuccessfulUpdateTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/TableLooksCorrectTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdateCompleteMessageTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdateErrorTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdateFailedTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdateWarningTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdaterFormTestBase.php create mode 100644 core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateErrorTest.php create mode 100644 core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateSettingsFormTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/ConsoleUpdateStageTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/CronUpdateRunnerTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/RequestedUpdateValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/StatusCheckerTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicy/SupportedBranchInstalledTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/StatusCheck/WindowsValidatorTest.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/UpdateStageTest.php create mode 100644 core/modules/auto_updates/tests/src/Traits/EmailNotificationsTestTrait.php create mode 100644 core/modules/auto_updates/tests/src/Traits/TestSetUpTrait.php create mode 100644 core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php create mode 100644 core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php create mode 100644 core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php 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/FileSyncerFactory.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/NoSymlinksPointToADirectory.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/ProxyClass/PackageManagerUninstallValidator.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/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/ExecutableFinderTest.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/FileSyncerFactoryTest.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/InstalledPackageTest.php create mode 100644 core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.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 create mode 100644 core/scripts/auto-update diff --git a/composer.json b/composer.json index 78a8fa159d4b..1f5f5f90a39d 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "phpstan/phpstan-phpunit": "^1.3.11", "phpunit/phpunit": "^9.6.13", "symfony/browser-kit": "^6.4", + "symfony/config": "^6.4", "symfony/css-selector": "^6.4", "symfony/dom-crawler": "^6.4", "symfony/error-handler": "^6.4", diff --git a/composer.lock b/composer.lock index 6a5cfd4b5ff1..49a83bb9eb00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9103791a18c3a090bfd3335ff65a404a", + "content-hash": "3e7d3ca2e83c2327f85443bc621feb93", "packages": [ { "name": "asm89/stack-cors", @@ -495,7 +495,7 @@ "dist": { "type": "path", "url": "core", - "reference": "8e8088fe577c4e4e1e961f9c36ce57b4cbb9ce51" + "reference": "7f7c20bc2f1ad42fe78223e862df0c5be8ce7575" }, "require": { "asm89/stack-cors": "^2.1", @@ -522,6 +522,7 @@ "mck89/peast": "^1.14", "pear/archive_tar": "^1.4.14", "php": ">=8.1.0", + "php-tuf/composer-stager": "^2.0.0-beta3", "psr/log": "^3.0", "sebastian/diff": "^4", "symfony/console": "^6.4", @@ -1463,6 +1464,87 @@ }, "time": "2021-03-21T15:43:46+00:00" }, + { + "name": "php-tuf/composer-stager", + "version": "v2.0.0-beta3", + "source": { + "type": "git", + "url": "https://github.com/php-tuf/composer-stager.git", + "reference": "9dd34d00626cf85eefc14aabb3ec73e02745d27a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-tuf/composer-stager/zipball/9dd34d00626cf85eefc14aabb3ec73e02745d27a", + "reference": "9dd34d00626cf85eefc14aabb3ec73e02745d27a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1.0", + "symfony/filesystem": "^6.2", + "symfony/process": "^6.2", + "symfony/translation-contracts": "^3.2" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.33", + "jangregor/phpstan-prophecy": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpbench/phpbench": "^1.2", + "phpmd/phpmd": "^2.13", + "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-deprecation-rules": "^1.1", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^9.6", + "psalm/phar": "^5.13", + "rector/rector": "^0.17.5 || ^0.18.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/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": "2023-08-30T14:37:37+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1937,16 +2019,16 @@ }, { "name": "symfony/console", - "version": "v6.4.1", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd" + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a550a7c99daeedef3f9d23fb82e3531525ff11fd", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd", + "url": "https://api.github.com/repos/symfony/console/zipball/0d9e4eb5ad413075624378f474c4167ea202de78", + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78", "shasum": "" }, "require": { @@ -2011,7 +2093,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.1" + "source": "https://github.com/symfony/console/tree/v6.4.4" }, "funding": [ { @@ -2027,7 +2109,7 @@ "type": "tidelift" } ], - "time": "2023-11-30T10:54:28+00:00" + "time": "2024-02-22T20:27:10+00:00" }, { "name": "symfony/dependency-injection", @@ -9103,6 +9185,81 @@ ], "time": "2023-10-31T08:18:17+00:00" }, + { + "name": "symfony/config", + "version": "v6.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/6ea4affc27f2086c9d16b92ab5429ce1e3c38047", + "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v6.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-26T07:52:26+00:00" + }, { "name": "symfony/css-selector", "version": "v6.4.0", diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json index 9c7df65c6d59..de37d028949e 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.14", "pear/pear_exception": "~v1.0.2", + "php-tuf/composer-stager": "~v2.0.0-beta3", "psr/cache": "~3.0.0", "psr/container": "~2.0.2", "psr/event-dispatcher": "~1.0.0", @@ -31,7 +32,7 @@ "psr/log": "~3.0.0", "ralouphie/getallheaders": "~3.0.3", "sebastian/diff": "~4.0.5", - "symfony/console": "~v6.4.1", + "symfony/console": "~v6.4.4", "symfony/dependency-injection": "~v6.4.1", "symfony/deprecation-contracts": "~v3.4.0", "symfony/error-handler": "~v6.4.0", diff --git a/composer/Metapackage/DevDependencies/composer.json b/composer/Metapackage/DevDependencies/composer.json index fef6d5681c9c..cef82dcee176 100644 --- a/composer/Metapackage/DevDependencies/composer.json +++ b/composer/Metapackage/DevDependencies/composer.json @@ -27,6 +27,7 @@ "phpstan/phpstan-phpunit": "^1.3.11", "phpunit/phpunit": "^9.6.13", "symfony/browser-kit": "^6.4", + "symfony/config": "^6.4", "symfony/css-selector": "^6.4", "symfony/dom-crawler": "^6.4", "symfony/error-handler": "^6.4", diff --git a/composer/Metapackage/PinnedDevDependencies/composer.json b/composer/Metapackage/PinnedDevDependencies/composer.json index b04ef07535c6..7f73a317e5e3 100644 --- a/composer/Metapackage/PinnedDevDependencies/composer.json +++ b/composer/Metapackage/PinnedDevDependencies/composer.json @@ -81,6 +81,7 @@ "slevomat/coding-standard": "8.14.1", "squizlabs/php_codesniffer": "3.8.0", "symfony/browser-kit": "v6.4.0", + "symfony/config": "v6.4.4", "symfony/css-selector": "v6.4.0", "symfony/dom-crawler": "v6.4.0", "symfony/lock": "v6.4.0", diff --git a/core/composer.json b/core/composer.json index d6ab934a7759..ef145f802e24 100644 --- a/core/composer.json +++ b/core/composer.json @@ -46,7 +46,8 @@ "pear/archive_tar": "^1.4.14", "psr/log": "^3.0", "mck89/peast": "^1.14", - "sebastian/diff": "^4" + "sebastian/diff": "^4", + "php-tuf/composer-stager": "^2.0.0-beta3" }, "conflict": { "drush/drush": "<12.4.3" diff --git a/core/lib/Drupal/Component/Diff/composer.json b/core/lib/Drupal/Component/Diff/composer.json index 7410e7afbdef..57a07592ff85 100644 --- a/core/lib/Drupal/Component/Diff/composer.json +++ b/core/lib/Drupal/Component/Diff/composer.json @@ -8,7 +8,7 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=8.1.0", - "sebastian/diff": "^4 | ^5" + "sebastian/diff": "^4" }, "autoload": { "psr-4": { diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 0f548ba5883c..61d72954d2ca 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -85,6 +85,7 @@ blockrelated blocktest bodyless boing +bootable bovigo brotli browserkit @@ -275,6 +276,7 @@ fieldlayout fieldlinks fieldnames fieldsets +filedate filemime filesystems filetransfer @@ -300,6 +302,7 @@ fulltext funic gabilondo gids +gitlabci gloop gnumeric googleapis @@ -314,6 +317,7 @@ hande harkonnen hateoas hexcode +hhvm hilited hinode hmac @@ -362,6 +366,7 @@ keyevent keypresses keyvalue kinberg +kirk kitt kolkata kpresenter @@ -572,6 +577,7 @@ presentationml pretransaction preuninstall processlist +proc_open proname prophesize prophesized @@ -685,6 +691,8 @@ squiz squizlabs srcset ssess +stage's +stager's stardivision starterkit statuscode @@ -739,6 +747,9 @@ subview sulaco supercede svgz +syncer +Syncer +syncers synchronizable syrop tabbingmanager @@ -833,6 +844,7 @@ unpromote unpublish unpublishes unpublishing +unrequested unrevisionable unrouted unsanitized @@ -842,8 +854,10 @@ unserializes unserializing unsets unsetting +unshallow unsticky untabbable +unwritable upcasted upcasting updateprogress diff --git a/core/modules/auto_updates/.gitlab-ci.yml b/core/modules/auto_updates/.gitlab-ci.yml new file mode 100644 index 000000000000..b911a1db196b --- /dev/null +++ b/core/modules/auto_updates/.gitlab-ci.yml @@ -0,0 +1,241 @@ +################ +# DrupalCI GitLabCI template +# +# Gitlab-ci.yml to replicate DrupalCI testing for Contrib +# +# With thanks to: +# * The GitLab Acceleration Initiative participants +# * DrupalSpoons +################ + +################ +# Guidelines +# +# This template is designed to give any Contrib maintainer everything they need to test, without requiring modification. It is also designed to keep up to date with Core Development automatically through the use of include files that can be centrally maintained. +# +# However, you can modify this template if you have additional needs for your project. +################ + +################ +# Includes +# +# Additional configuration can be provided through includes. +# One advantage of include files is that if they are updated upstream, the changes affect all pipelines using that include. +# +# Includes can be overridden by re-declaring anything provided in an include, here in gitlab-ci.yml +# https://docs.gitlab.com/ee/ci/yaml/includes.html#override-included-configuration-values +################ + +include: + ################ + # DrupalCI includes: + # As long as you include this, any future includes added by the Drupal Association will be accessible to your pipelines automatically. + # View these include files at https://git.drupalcode.org/project/gitlab_templates/ + ################ + - project: $_GITLAB_TEMPLATES_REPO + # "ref" value can be: + # - Recommended (default) - `ref: $_GITLAB_TEMPLATES_REF` - The Drupal Association will update this value to the recommended tag for contrib. + # - Latest - `ref: main` - Get the latest additions and bug fixes as they are merged into the templates. + # - Minor or Major latests - `ref: 1.x-latest` or `ref: 1.0.x-latest` - Get the latest additions within a minor (mostly bugfixes) or major (bugs and new features). + # - Fixed tag - `ref: 1.0.1` - Set the value to a known tag. This will not get any updates. + # If you change the default value of ref, you should set the _CURL_TEMPLATES_REF variable in the variables section to be the same as set here. + ref: main + file: + - '/includes/include.drupalci.main.yml' + # EXPERIMENTAL: For Drupal 7, remove the above line and uncomment the below. + # - '/includes/include.drupalci.main-d7.yml' + - '/includes/include.drupalci.variables.yml' + - '/includes/include.drupalci.workflows.yml' + +################ +# Pipeline configuration variables +# +# These are the variables provided to the Run Pipeline form that a user may want to override. +# +# Docs at https://git.drupalcode.org/project/gitlab_templates/-/blob/1.0.x/includes/include.drupalci.variables.yml +################ +variables: + _PHPUNIT_CONCURRENT: '1' + _PHPUNIT_TESTGROUPS: '' + # Always test against the previous minor version of core. + OPT_IN_TEST_PREVIOUS_MINOR: '1' + # Test against the next major version of core, with Automatic Updates and + # Package Manager included as core modules. + OPT_IN_TEST_NEXT_MAJOR: '1' + # @todo Remove this line when https://drupal.org/i/3414093 is fixed. + CI_DEBUG_SERVICES: "true" + _TARGET_DB_TYPE: "mariadb" + _TARGET_DB_VERSION: "10.6" + + +################################################################################### +# +# * +# /( +# ((((, +# /((((((( +# ((((((((((* +# ,((((((((((((((( +# ,((((((((((((((((((( +# ((((((((((((((((((((((((* +# *((((((((((((((((((((((((((((( +# ((((((((((((((((((((((((((((((((((* +# *(((((((((((((((((( .(((((((((((((((((( +# ((((((((((((((((((. /(((((((((((((((((* +# /((((((((((((((((( .(((((((((((((((((, +# ,(((((((((((((((((( (((((((((((((((((( +# .(((((((((((((((((((( .((((((((((((((((( +# ((((((((((((((((((((((( ((((((((((((((((/ +# (((((((((((((((((((((((((((/ ,(((((((((((((((* +# .((((((((((((((/ /(((((((((((((. ,((((((((((((((( +# *(((((((((((((( ,(((((((((((((/ *((((((((((((((. +# ((((((((((((((, /(((((((((((((. ((((((((((((((, +# (((((((((((((/ ,(((((((((((((* ,(((((((((((((, +# *((((((((((((( .((((((((((((((( ,((((((((((((( +# ((((((((((((/ /((((((((((((((((((. ,((((((((((((/ +# ((((((((((((( *(((((((((((((((((((((((* *(((((((((((( +# ((((((((((((( ,(((((((((((((..((((((((((((( *(((((((((((( +# ((((((((((((, /((((((((((((* /((((((((((((/ (((((((((((( +# ((((((((((((( /((((((((((((/ (((((((((((((* (((((((((((( +# (((((((((((((/ /(((((((((((( ,((((((((((((, *(((((((((((( +# (((((((((((((( *(((((((((((/ *((((((((((((. ((((((((((((/ +# *((((((((((((((((((((((((((, /((((((((((((((((((((((((( +# ((((((((((((((((((((((((( ((((((((((((((((((((((((, +# .(((((((((((((((((((((((/ ,((((((((((((((((((((((( +# ((((((((((((((((((((((/ ,(((((((((((((((((((((/ +# *((((((((((((((((((((( (((((((((((((((((((((, +# ,(((((((((((((((((((((, ((((((((((((((((((((/ +# ,(((((((((((((((((((((* /(((((((((((((((((((( +# ((((((((((((((((((((((, ,/((((((((((((((((((((, +# ,((((((((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((((((((((( +# .((((((((((((((((((((((((((((((((((((,. +# .,(((((((((((((((((((((((((. +# +################################################################################### + +.extra-variables: &extra-variables + - MODULE_DIR=$_WEB_ROOT/modules/custom/$CI_PROJECT_NAME + # Strip the `-dev` suffix out of $_TARGET_CORE to reveal the name of the Drupal core branch. + - CORE_BRANCH=`echo $_TARGET_CORE | sed -e "s/-dev$//"` + +composer: + extends: .composer-base + before_script: + # In this case, we want to archive everything. + - rm .gitattributes + - composer archive --file=module + after_script: + - *extra-variables + - rm -r -f $MODULE_DIR + - mkdir -p $MODULE_DIR + - tar -x -f module.tar -C $MODULE_DIR + - rm module.tar + - git clone https://git.drupalcode.org/project/drupal.git --depth 1 --branch $CORE_BRANCH /tmp/drupal-core + - cp -R /tmp/drupal-core/composer . + # Copy to web as well as \Drupal\Composer\Composer will expect it be there. + - cp -R /tmp/drupal-core/composer web + +composer (previous minor): + before_script: + - !reference [composer, before_script] + after_script: + - !reference [composer, after_script] + +composer (next major): + before_script: + # Remove the one core patch we currently apply. We can do this because it does + # not affect Package Manager or Automatic Updates at all. We should remove this + # line once we make the converter command's calls to `patch` work properly on + # GitLab CI. + - rm ./scripts/core-patches/3331078-allow-beta.patch + - !reference [composer, before_script] + after_script: + - !reference [composer, after_script] + # Convert Automatic Updates and Package Manager to core modules. + - composer run core-convert --working-dir=$MODULE_DIR -- $CI_PROJECT_DIR/$_WEB_ROOT --gitlabci_path=$CI_PROJECT_DIR --core_target_branch=$CORE_BRANCH + # Confirm that both modules are now in core, then remove the contrib module. + - test -d $_WEB_ROOT/core/modules/auto_updates + - test -d $_WEB_ROOT/core/modules/package_manager + - rm -r -f $MODULE_DIR + +composer (previous minor): + before_script: + - !reference [composer, before_script] + after_script: + - !reference [composer, after_script] + +phpcs: + before_script: + # Use core's PHPCS configuration. + - cp $_WEB_ROOT/core/phpcs.xml.dist . + +phpstan: + before_script: + - *extra-variables + # Ensure our PHPStan configuration has the correct include path to core's PHPStan configuration. + - sed -i "s#%rootDir%/../../../#%rootDir%/../../../$_WEB_ROOT/#" $MODULE_DIR/phpstan.neon + +# Disable this job entirely; the modules are moved to core. +phpstan (next major): + rules: + - when: never + +phpunit: + parallel: + matrix: + - MODULE: + - auto_updates_extensions + - auto_updates + - package_manager + TEST_TYPE: + - Unit + - Kernel + - Functional + - FunctionalJavascript + - Build + rules: + # Automatic Updates Extensions has no unit or functional JavaScript tests. + - if: $MODULE == "auto_updates_extensions" && ($TEST_TYPE == "Unit" || $TEST_TYPE == "FunctionalJavascript") + when: never + # Package Manager has no functional JavaScript tests. + - if: $MODULE == "package_manager" && $TEST_TYPE == "FunctionalJavascript" + when: never + - when: on_success + variables: + _PHPUNIT_EXTRA: '--types PHPUnit-$TEST_TYPE --module $MODULE' + +phpunit (previous minor): + rules: + - !reference [phpunit, rules] + +phpunit (next major): + allow_failure: false + parallel: + matrix: + - MODULE: + - auto_updates + - package_manager + TEST_TYPE: + - Unit + - Kernel + - Functional + - FunctionalJavascript + - Build + rules: + - if: $OPT_IN_TEST_NEXT_MAJOR != "1" || $SKIP_PHPUNIT == "1" + when: never + # Package Manager has no functional JavaScript tests. + - if: $MODULE == "package_manager" && $TEST_TYPE == "FunctionalJavascript" + when: never + - when: on_success + variables: + _PHPUNIT_EXTRA: '--types PHPUnit-$TEST_TYPE --module $MODULE' + # This is a really dirty hack to work around a possible bug in Drush when it runs against + # Drupal core 11.x-dev: + # https://git.drupalcode.org/project/auto_updates/-/jobs/723857#L209 + # @todo Remove this bit as soon as possible. + before_script: + - echo '#!/bin/sh' > ./vendor/bin/drush + - echo 'exit 0' >> ./vendor/bin/drush + - chmod +x ./vendor/bin/drush diff --git a/core/modules/auto_updates/auto_updates.info.yml b/core/modules/auto_updates/auto_updates.info.yml new file mode 100644 index 000000000000..8f3b0999b2b0 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.info.yml @@ -0,0 +1,9 @@ +name: 'Automatic Updates' +type: module +description: 'Automatically updates Drupal core.' +package: Core +version: VERSION +lifecycle: experimental +dependencies: + - drupal:package_manager + - drupal:update diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install new file mode 100644 index 000000000000..5260361c3d72 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.install @@ -0,0 +1,43 @@ +<?php + +/** + * @file + * Contains install and update functions for Automatic Updates. + */ + +declare(strict_types = 1); + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\UpdateStage; +use Drupal\auto_updates\Validation\StatusCheckRequirements; +use Drupal\system\SystemManager; + +/** + * Implements hook_uninstall(). + */ +function auto_updates_uninstall() { + \Drupal::service(UpdateStage::class)->destroy(TRUE); +} + +/** + * Implements hook_requirements(). + */ +function auto_updates_requirements($phase) { + if ($phase === 'runtime') { + // Check that site is ready to perform automatic updates. + /** @var \Drupal\auto_updates\Validation\StatusCheckRequirements $status_check_requirement */ + $status_check_requirement = \Drupal::classResolver(StatusCheckRequirements::class); + $requirements = $status_check_requirement->getRequirements(); + + // Check that site has cron updates enabled or not. + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + if (\Drupal::configFactory()->get('auto_updates.settings')->get('unattended.level') !== CronUpdateRunner::DISABLED) { + $requirements['auto_updates_cron'] = [ + 'title' => t('Cron installs updates automatically'), + 'severity' => SystemManager::REQUIREMENT_WARNING, + 'value' => t('Enabled. This is NOT an officially supported feature of the Automatic Updates module at this time. Use at your own risk.'), + ]; + } + return $requirements; + } +} diff --git a/core/modules/auto_updates/auto_updates.libraries.yml b/core/modules/auto_updates/auto_updates.libraries.yml new file mode 100644 index 000000000000..6d61739cee21 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.libraries.yml @@ -0,0 +1,4 @@ +update_status: + css: + theme: + css/update-status.css: {} diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module new file mode 100644 index 000000000000..0ad513b99594 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.module @@ -0,0 +1,311 @@ +<?php + +/** + * @file + * Contains hook implementations for Automatic Updates. + */ + +declare(strict_types = 1); + +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ReleaseChooser; +use Drupal\auto_updates\UpdateStage; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\auto_updates\Validation\AdminStatusCheckMessages; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\Core\Utility\Error; +use Drupal\system\Controller\DbUpdateController; + +/** + * Implements hook_help(). + */ +function auto_updates_help($route_name, RouteMatchInterface $route_match) { + switch ($route_name) { + case 'help.page.auto_updates': + $output = '<h3>' . t('About') . '</h3>'; + $output .= '<p>' . t('Automatic Updates lets you update Drupal core.') . '</p>'; + $output .= '<p>'; + $output .= t('Automatic Updates will keep Drupal secure and up-to-date by automatically installing new patch-level updates, if available, when cron runs. It also provides a user interface to check if any updates are available and install them. You can <a href=":configure-form">configure Automatic Updates</a> to install all patch-level updates, only security updates, or no updates at all, during cron. By default, only security updates are installed during cron; this requires that you <a href=":update-form">install non-security updates through the user interface</a>.', [ + ':configure-form' => Url::fromRoute('update.settings')->toString(), + ':update-form' => Url::fromRoute('update.report_update')->toString(), + ]); + $output .= '</p>'; + $output .= '<p>' . t('Additionally, Automatic Updates periodically runs checks to ensure that updates can be installed, and will warn site administrators if problems are detected.') . '</p>'; + $output .= '<h3>' . t('Requirements') . '</h3>'; + $output .= '<p>' . t('Automatic Updates requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</p>'; + $output .= '<p>' . t('For more information, see the <a href=":automatic-updates-documentation">online documentation for the Automatic Updates module</a>.', [':automatic-updates-documentation' => 'https://www.drupal.org/docs/8/update/automatic-updates']) . '</p>'; + $output .= '<h3 id="minor-update">' . t('Updating to another minor version of Drupal') . '</h3>'; + $output .= '<p>'; + $output .= t('Automatic Updates supports updating from one minor version of Drupal core to another; for example, from Drupal 9.4.8 to Drupal 9.5.0. This is only allowed when updating via <a href=":url">the user interface</a>. Unattended background updates can only update <em>within</em> the currently installed minor version (for example, Drupal 9.4.6 to 9.4.8).', [ + ':url' => Url::fromRoute('update.report_update')->toString(), + ]); + $output .= '</p>'; + $output .= '<p>' . t('This is because updating from one minor version of Drupal to another is riskier than staying within the current minor version. New minor versions of Drupal introduce changes that can, in some situations, be incompatible with installed modules and themes.') . '</p>'; + $output .= '<p>' . t('Therefore, if you want to use Automatic Updates to update to another minor version of Drupal, it is strongly recommended to do a test update first, ideally on an isolated development copy of your site, before updating your production site.') . '</p>'; + return $output; + } +} + +/** + * Implements hook_mail(). + */ +function auto_updates_mail(string $key, array &$message, array $params): void { + // Explicitly pass the language code to all translated strings. + $options = [ + 'langcode' => $message['langcode'], + ]; + if ($key === 'cron_successful') { + $message['subject'] = t("Drupal core was successfully updated", [], $options); + $message['body'][] = t('Congratulations!', [], $options); + $message['body'][] = t('Drupal core was automatically updated from @previous_version to @updated_version.', [ + '@previous_version' => $params['previous_version'], + '@updated_version' => $params['updated_version'], + ], $options); + } + elseif (str_starts_with($key, 'cron_failed')) { + $message['subject'] = t("Drupal core update failed", [], $options); + + // If this is considered urgent, prefix the subject line with a call to + // action. + if ($params['urgent']) { + $message['subject'] = t('URGENT: @subject', [ + '@subject' => $message['subject'], + ], $options); + } + + $message['body'][] = t('Drupal core failed to update automatically from @previous_version to @target_version. The following error was logged:', [ + '@previous_version' => $params['previous_version'], + '@target_version' => $params['target_version'], + ], $options); + $message['body'][] = $params['error_message']; + + // If the problem was not due to a failed apply, provide a link for the site + // owner to do the update. + if ($key !== 'cron_failed_apply') { + $url = Url::fromRoute('update.report_update') + ->setAbsolute() + ->toString(); + + if ($key === 'cron_failed_insecure') { + $message['body'][] = t('Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit @url to perform the update.', ['@url' => $url], $options); + } + else { + $message['body'][] = t('No immediate action is needed, but it is recommended that you visit @url to perform the update, or at least check that everything still looks good.', ['@url' => $url], $options); + } + } + } + elseif ($key === 'status_check_failed') { + $message['subject'] = t('Automatic updates readiness checks failed', [], $options); + + $url = Url::fromRoute('system.status') + ->setAbsolute() + ->toString(); + $message['body'][] = t('Your site has failed some readiness checks for automatic updates and may not be able to receive automatic updates until further action is taken. Visit @url for more information.', [ + '@url' => $url, + ], $options); + } + + // If this email was related to an unattended update, explicitly state that + // this isn't supported yet. + if (str_starts_with($key, 'cron_')) { + $message['body'][] = t('This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported.', [], $options); + $message['body'][] = t('If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.', [], $options); + } +} + +/** + * Implements hook_page_top(). + */ +function auto_updates_page_top(array &$page_top) { + // Ensure error messages will be displayed on the batch page. + // @todo Remove this work around when https://drupal.org/i/3406612 is fixed. + if (\Drupal::routeMatch()->getRouteName() === 'system.batch_page.html') { + // Directly render a status message placeholder without any messages. + // Messages are not intended to be show on the batch page, but in the event + // an error in a AJAX callback the messages will be displayed. + $page_top['messages'] = [ + '#theme' => 'status_messages', + '#message_list' => [], + '#status_headings' => [ + 'status' => t('Status message'), + 'error' => t('Error message'), + 'warning' => t('Warning message'), + ], + ]; + } + /** @var \Drupal\auto_updates\Validation\AdminStatusCheckMessages $status_check_messages */ + $status_check_messages = \Drupal::classResolver(AdminStatusCheckMessages::class); + $status_check_messages->displayAdminPageMessages(); + + // @todo Rely on the route option after https://www.drupal.org/i/3236497 is + // committed. + $skip_routes = [ + 'auto_updates.confirmation_page', + 'auto_updates.report_update', + 'auto_updates.module_update', + ]; + // @see auto_updates_module_implements_alter() + $route_name = \Drupal::routeMatch()->getRouteName(); + if (!in_array($route_name, $skip_routes, TRUE) && function_exists('update_page_top')) { + update_page_top(); + } +} + +/** + * Implements hook_module_implements_alter(). + * + * @todo Remove after https://www.drupal.org/i/3236497 is committed. + */ +function auto_updates_module_implements_alter(&$implementations, $hook) { + if ($hook === 'page_top') { + // Remove hook_page_top() implementation from the Update module. This ' + // implementation displays error messages about security releases. We call + // this implementation in our own auto_updates_page_top() except on our + // own routes to avoid these messages while an update is in progress. + unset($implementations['update']); + } +} + +/** + * Implements hook_modules_installed(). + */ +function auto_updates_modules_installed($modules) { + // Run the status checkers if needed when any modules are installed in + // case they provide status checkers. + /** @var \Drupal\auto_updates\Validation\StatusChecker $status_checker */ + $status_checker = \Drupal::service(StatusChecker::class); + $status_checker->run(); + /** @var \Drupal\auto_updates\CronUpdateRunner $runner */ + $runner = \Drupal::service(CronUpdateRunner::class); + // If cron updates are disabled status check messages will not be displayed on + // admin pages. Therefore, after installing the module the user will not be + // alerted to any problems until they access the status report page. + if ($runner->getMode() === CronUpdateRunner::DISABLED) { + /** @var \Drupal\auto_updates\Validation\AdminStatusCheckMessages $status_check_messages */ + $status_check_messages = \Drupal::classResolver(AdminStatusCheckMessages::class); + $status_check_messages->displayResultSummary(); + } +} + +/** + * Implements hook_modules_uninstalled(). + */ +function auto_updates_modules_uninstalled() { + // Run the status checkers if needed when any modules are uninstalled in + // case they provided status checkers. + /** @var \Drupal\auto_updates\Validation\StatusChecker $status_checker */ + $status_checker = \Drupal::service(StatusChecker::class); + $status_checker->run(); +} + +/** + * Implements hook_batch_alter(). + * + * @todo Remove this in https://www.drupal.org/i/3267817. + */ +function auto_updates_batch_alter(array &$batch): void { + foreach ($batch['sets'] as &$batch_set) { + if (!empty($batch_set['finished']) && $batch_set['finished'] === [DbUpdateController::class, 'batchFinished']) { + $batch_set['finished'] = [BatchProcessor::class, 'dbUpdateBatchFinished']; + } + } +} + +/** + * Implements hook_form_FORM_ID_alter() for update_settings. + */ +function auto_updates_form_update_settings_alter(array &$form): void { + $config = \Drupal::config('auto_updates.settings'); + + $form['unattended_level'] = [ + '#type' => 'radios', + '#title' => t('Unattended background updates'), + '#options' => [ + CronUpdateRunner::DISABLED => t('Disabled'), + CronUpdateRunner::SECURITY => t('Security updates only'), + CronUpdateRunner::ALL => t('All patch releases'), + ], + '#default_value' => $config->get('unattended.level'), + '#description' => t('When background updates are applied, your site will be briefly put into maintenance mode.'), + ]; + $form['unattended_method'] = [ + '#type' => 'radios', + '#title' => t('How unattended updates should be run'), + '#options' => [ + 'web' => t('By using the Automated Cron module or a request to /system/cron'), + 'console' => t('By the <code>auto-update</code> command-line utility'), + ], + '#default_value' => $config->get('unattended.method'), + '#states' => [ + 'invisible' => [ + 'input[name="unattended_level"]' => [ + 'value' => CronUpdateRunner::DISABLED, + ], + ], + ], + '#description' => t('To use the <code>/system/cron</code> method <a href="http://drupal.org/docs/user_guide/en/security-cron.html">ensure cron is set up correctly</a>.'), + ]; + $form['#submit'][] = '_auto_updates_submit_update_settings'; +} + +/** + * Saves settings for unattended updates. + * + * @param array $form + * The complete form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ +function _auto_updates_submit_update_settings(array &$form, FormStateInterface $form_state): void { + \Drupal::configFactory() + ->getEditable('auto_updates.settings') + ->set('unattended', [ + 'method' => $form_state->getValue('unattended_method'), + 'level' => $form_state->getValue('unattended_level'), + ]) + ->save(); +} + +/** + * Implements hook_preprocess_update_project_status(). + */ +function auto_updates_preprocess_update_project_status(array &$variables) { + $project = &$variables['project']; + if ($project['name'] !== 'drupal') { + return; + } + $stage = \Drupal::service(UpdateStage::class); + $supported_target_versions = []; + /** @var \Drupal\auto_updates\ReleaseChooser $recommender */ + $recommender = \Drupal::service(ReleaseChooser::class); + try { + if ($installed_minor_release = $recommender->getLatestInInstalledMinor($stage)) { + $supported_target_versions[] = $installed_minor_release->getVersion(); + } + if ($next_minor_release = $recommender->getLatestInNextMinor($stage)) { + $supported_target_versions[] = $next_minor_release->getVersion(); + } + } + catch (RuntimeException $exception) { + // If for some reason we are not able to get the update recommendations + // do not alter the report. + Error::logException(\Drupal::logger('auto_updates'), $exception); + return; + } + $variables['#attached']['library'][] = 'auto_updates/update_status'; + + $status = &$variables['status']; + if ($supported_target_versions && $status['label']) { + $status['label'] = [ + '#markup' => t( + '@label <a href=":update-form">Update now</a>', [ + '@label' => $status['label'], + ':update-form' => Url::fromRoute('update.report_update')->toString(), + ]), + ]; + } +} diff --git a/core/modules/auto_updates/auto_updates.post_update.php b/core/modules/auto_updates/auto_updates.post_update.php new file mode 100644 index 000000000000..a2662a6c5f2c --- /dev/null +++ b/core/modules/auto_updates/auto_updates.post_update.php @@ -0,0 +1,19 @@ +<?php + +/** + * @file + * Contains post-update hooks for Automatic Updates. + * + * DELETE THIS FILE FROM CORE MERGE REQUEST. + */ + +declare(strict_types = 1); + +/** + * Implements hook_removed_post_updates(). + */ +function auto_updates_removed_post_updates(): array { + return [ + 'auto_updates_post_update_create_status_check_mail_config' => '3.0.0', + ]; +} diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml new file mode 100644 index 000000000000..bb6185dbfa58 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.routing.yml @@ -0,0 +1,29 @@ +auto_updates.status_check: + path: '/admin/auto_updates/status' + defaults: + _controller: '\Drupal\auto_updates\Controller\StatusCheckController::run' + _title: 'Update readiness checking' + requirements: + _permission: 'administer software updates' + options: + _maintenance_access: TRUE + _auto_updates_status_messages: skip +auto_updates.confirmation_page: + path: '/admin/automatic-update-ready/{stage_id}' + defaults: + _form: '\Drupal\auto_updates\Form\UpdateReady' + _title: 'Ready to update' + requirements: + _permission: 'administer software updates' + options: + _maintenance_access: TRUE + _auto_updates_status_messages: skip +auto_updates.finish: + path: '/automatic-update/finish' + defaults: + _controller: '\Drupal\auto_updates\Controller\UpdateController::onFinish' + requirements: + _permission: 'administer software updates' + options: + _maintenance_access: TRUE + _auto_updates_status_messages: skip diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml new file mode 100644 index 000000000000..ceb33d4b310e --- /dev/null +++ b/core/modules/auto_updates/auto_updates.services.yml @@ -0,0 +1,58 @@ +services: + _defaults: + autowire: true + + Drupal\auto_updates\Routing\RouteSubscriber: + tags: + - { name: event_subscriber } + Drupal\auto_updates\Validation\StatusChecker: + arguments: + # @todo Remove this when https://drupal.org/i/3325557 lands. + $key_value_expirable_factory: '@keyvalue.expirable' + $resultsTimeToLive: 24 + tags: + - { name: event_subscriber } + Drupal\auto_updates\StatusCheckMailer: ~ + Drupal\auto_updates\UpdateStage: + calls: + - ['setLogger', ['@logger.channel.auto_updates']] + Drupal\auto_updates\CronUpdateRunner: + calls: + - ['setLogger', ['@logger.channel.auto_updates']] + decorates: 'cron' + Drupal\auto_updates\Validator\RequestedUpdateValidator: + tags: + - { name: event_subscriber } + Drupal\auto_updates\Validator\StagedProjectsValidator: + tags: + - { name: event_subscriber } + Drupal\auto_updates\ReleaseChooser: ~ + Drupal\auto_updates\Validator\CronFrequencyValidator: + arguments: + $lock: '@lock' + tags: + - { name: event_subscriber } + Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator: + tags: + - { name: event_subscriber } + Drupal\auto_updates\Validator\VersionPolicyValidator: + tags: + - { name: event_subscriber } + Drupal\auto_updates\Validator\WindowsValidator: + tags: + - { name: event_subscriber } + logger.channel.auto_updates: + parent: logger.channel_base + arguments: ['auto_updates'] + Drupal\auto_updates\ConsoleUpdateStage: + arguments: + $lock: '@lock' + $committer: '@Drupal\auto_updates\MaintenanceModeAwareCommitter' + calls: + - ['setLogger', ['@logger.channel.auto_updates']] + Drupal\auto_updates\MaintenanceModeAwareCommitter: + tags: + - { name: event_subscriber } + Drupal\auto_updates\CommandExecutor: + arguments: + $appRoot: '%app.root%' diff --git a/core/modules/auto_updates/config/install/auto_updates.settings.yml b/core/modules/auto_updates/config/install/auto_updates.settings.yml new file mode 100644 index 000000000000..cb20706e33a8 --- /dev/null +++ b/core/modules/auto_updates/config/install/auto_updates.settings.yml @@ -0,0 +1,5 @@ +unattended: + method: web + level: disable +allow_core_minor_updates: false +status_check_mail: errors_only diff --git a/core/modules/auto_updates/config/schema/auto_updates.schema.yml b/core/modules/auto_updates/config/schema/auto_updates.schema.yml new file mode 100644 index 000000000000..017228b1f361 --- /dev/null +++ b/core/modules/auto_updates/config/schema/auto_updates.schema.yml @@ -0,0 +1,29 @@ +auto_updates.settings: + type: config_object + label: 'Automatic Updates settings' + mapping: + unattended: + type: mapping + label: 'Settings for unattended update' + mapping: + method: + type: string + label: 'Method of running unattended updates' + constraints: + NotNull: [] + Choice: [console, web] + level: + type: string + label: 'Which level of unattended updates to perform' + constraints: + NotNull: [] + Choice: [disable, security, patch] + cron_port: + type: integer + label: 'Port to use for finalization sub-request' + allow_core_minor_updates: + type: boolean + label: 'Allow minor level Drupal core updates' + status_check_mail: + type: string + label: 'Whether to send status check failure email notifications during cron' diff --git a/core/modules/auto_updates/css/update-status.css b/core/modules/auto_updates/css/update-status.css new file mode 100644 index 000000000000..62adba0eb4b1 --- /dev/null +++ b/core/modules/auto_updates/css/update-status.css @@ -0,0 +1,8 @@ +/** + * @file + * Styles used by the Automatic Updates module. + */ + +.automatic-updates-unsupported-version .project-update__download-link { + display: none; +} diff --git a/core/modules/auto_updates/phpstan.neon b/core/modules/auto_updates/phpstan.neon new file mode 100644 index 000000000000..fd16bd0054f8 --- /dev/null +++ b/core/modules/auto_updates/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - %rootDir%/../../../core/phpstan.neon.dist + +parameters: + ignoreErrors: + # Drupal core's PHPStan config file ignores the non-anonymous variant of this. + - "#^Anonymous class extends @internal class#" + # Drupal core needs to ignore more things than we need to! + reportUnmatchedIgnoredErrors: false diff --git a/core/modules/auto_updates/src/AutoUpdatesServiceProvider.php b/core/modules/auto_updates/src/AutoUpdatesServiceProvider.php new file mode 100644 index 000000000000..f3f2cc6075b5 --- /dev/null +++ b/core/modules/auto_updates/src/AutoUpdatesServiceProvider.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\auto_updates\Validator\PhpExtensionsValidator; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\package_manager\Validator\PhpExtensionsValidator as PackageManagerPhpExtensionsValidator; + +/** + * Modifies container services for Automatic Updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class AutoUpdatesServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container) { + $service_id = PackageManagerPhpExtensionsValidator::class; + + if ($container->hasDefinition($service_id)) { + $container->getDefinition($service_id) + ->setClass(PhpExtensionsValidator::class); + } + } + +} diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php new file mode 100644 index 000000000000..ebb4f03f0f09 --- /dev/null +++ b/core/modules/auto_updates/src/BatchProcessor.php @@ -0,0 +1,281 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\Core\Url; +use Drupal\system\Controller\DbUpdateController; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * A batch processor for updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class BatchProcessor { + + /** + * The session key under which the stage ID is stored. + * + * @var string + */ + public const STAGE_ID_SESSION_KEY = '_auto_updates_stage_id'; + + /** + * The session key which indicates if the update is done in maintenance mode. + * + * @var string + */ + public const MAINTENANCE_MODE_SESSION_KEY = '_auto_updates_maintenance_mode'; + + /** + * The session key which stores error messages that occur in processing. + * + * @var string + */ + private const ERROR_MESSAGES_SESSION_KEY = '_auto_updates_errors'; + + /** + * Gets the update stage service. + * + * @return \Drupal\auto_updates\UpdateStage + * The update stage service. + */ + private static function getStage(): UpdateStage { + return \Drupal::service(UpdateStage::class); + } + + /** + * Stores an error message for later display. + * + * @param string $error_message + * The error message. + */ + private static function storeErrorMessage(string $error_message): void { + // TRICKY: We need to store error messages in the session because the batch + // context becomes a dangling reference when static variables are globally + // reset by drupal_flush_all_caches(), which is called during the post-apply + // phase of the update. Which means that, when ::postApply() is called, any + // data added to the batch context in the current request is lost. On the + // other hand, data stored in the session is not affected. + /** @var \Symfony\Component\HttpFoundation\Session\SessionInterface $session */ + $session = \Drupal::service('session'); + $errors = $session->get(self::ERROR_MESSAGES_SESSION_KEY, []); + $errors[] = $error_message; + $session->set(self::ERROR_MESSAGES_SESSION_KEY, $errors); + } + + /** + * Calls the update stage's begin() method. + * + * @param string[] $project_versions + * The project versions to be staged in the update, keyed by package name. + * + * @see \Drupal\auto_updates\UpdateStage::begin() + */ + public static function begin(array $project_versions): void { + try { + $stage_id = static::getStage()->begin($project_versions); + \Drupal::service('session')->set(static::STAGE_ID_SESSION_KEY, $stage_id); + } + catch (\Throwable $e) { + static::storeErrorMessage($e->getMessage()); + throw $e; + } + } + + /** + * Calls the update stage's stage() method. + * + * @see \Drupal\auto_updates\UpdateStage::stage() + */ + public static function stage(): void { + $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); + $stage = static::getStage(); + try { + $stage->claim($stage_id)->stage(); + } + catch (\Throwable $e) { + // If the stage was not already destroyed because of this exception + // destroy it. + if (!$stage->isAvailable()) { + static::clean($stage_id); + } + static::storeErrorMessage($e->getMessage()); + throw $e; + } + } + + /** + * Calls the update stage's apply() method. + * + * @param string $stage_id + * The stage ID. + * + * @see \Drupal\auto_updates\UpdateStage::apply() + */ + public static function commit(string $stage_id): void { + try { + static::getStage()->claim($stage_id)->apply(); + // The batch system does not allow any single request to run for longer + // than a second, so this will force the next operation to be done in a + // new request. This helps keep the running code in as consistent a state + // as possible. + // @see \Drupal\package_manager\Stage::apply() + // @see \Drupal\package_manager\Stage::postApply() + sleep(1); + } + catch (\Throwable $e) { + static::storeErrorMessage($e->getMessage()); + throw $e; + } + } + + /** + * Calls the update stage's postApply() method. + * + * @param string $stage_id + * The stage ID. + * + * @see \Drupal\auto_updates\UpdateStage::postApply() + */ + public static function postApply(string $stage_id): void { + try { + static::getStage()->claim($stage_id)->postApply(); + } + catch (\Throwable $e) { + static::storeErrorMessage($e->getMessage()); + throw $e; + } + } + + /** + * Calls the update stage's destroy() method. + * + * @param string $stage_id + * The stage ID. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse|null + * A redirect response, or NULL to proceed to the normal finish page. + * + * @see \Drupal\auto_updates\UpdateStage::destroy() + */ + public static function clean(string $stage_id): ?RedirectResponse { + try { + static::getStage()->claim($stage_id)->destroy(); + return NULL; + } + catch (\Throwable $e) { + static::storeErrorMessage($e->getMessage()); + static::displayStoredErrorMessages(); + // If we failed to destroy the stage, the update still (mostly) succeeded, + // so forward the user to the finish page. They won't be able to start + // another update (or, indeed, any other Package Manager operation) until + // they destroy the existing stage anyway. + return static::finishCommit(TRUE); + } + } + + /** + * Finishes the stage batch job. + * + * @param bool $success + * Indicate that the batch API tasks were all completed successfully. + */ + public static function finishStage(bool $success): ?RedirectResponse { + if ($success) { + $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); + $url = Url::fromRoute('auto_updates.confirmation_page', [ + 'stage_id' => $stage_id, + ]); + return new RedirectResponse($url->setAbsolute()->toString()); + } + static::displayStoredErrorMessages(); + return NULL; + } + + /** + * Finishes the commit batch job. + * + * @param bool $success + * Indicate that the batch API tasks were all completed successfully. + */ + public static function finishCommit(bool $success): ?RedirectResponse { + \Drupal::service('session')->remove(static::STAGE_ID_SESSION_KEY); + + if ($success) { + $url = Url::fromRoute('auto_updates.finish') + ->setAbsolute() + ->toString(); + return new RedirectResponse($url); + } + static::displayStoredErrorMessages(); + return NULL; + } + + /** + * Displays any error messages that were stored in the session. + * + * @see ::storeErrorMessage() + */ + private static function displayStoredErrorMessages(): void { + /** @var \Symfony\Component\HttpFoundation\Session\SessionInterface $session */ + $session = \Drupal::service('session'); + $errors = $session->get(self::ERROR_MESSAGES_SESSION_KEY); + $session->remove(self::ERROR_MESSAGES_SESSION_KEY); + + if (is_array($errors)) { + array_walk($errors, \Drupal::messenger()->addError(...)); + } + else { + \Drupal::messenger()->addError("Update error"); + } + } + + /** + * Reset maintenance mode after update.php. + * + * This wraps \Drupal\system\Controller\DbUpdateController::batchFinished() + * because that function would leave the site in maintenance mode if we + * redirected the user to update.php already in maintenance mode. We need to + * take the site out of maintenance mode, if it was not enabled before they + * submitted our confirmation form. + * + * @param bool $success + * Whether the batch API tasks were all completed successfully. + * @param array $results + * An array of all the results. + * @param array $operations + * A list of the operations that had not been completed by the batch API. + * + * @todo Remove the need for this workaround in + * https://www.drupal.org/i/3267817. + * + * @see \Drupal\update\Form\UpdateReady::submitForm() + * @see auto_updates_batch_alter() + */ + public static function dbUpdateBatchFinished(bool $success, array $results, array $operations): void { + // Run status checks after database updates are completed to ensure that + // PendingUpdatesValidator does not report any errors. + // @see \Drupal\package_manager\Validator\PendingUpdatesValidator + /** @var \Drupal\auto_updates\Validation\StatusChecker $status_checker */ + $status_checker = \Drupal::service(StatusChecker::class); + $status_checker->run(); + DbUpdateController::batchFinished($success, $results, $operations); + // Now that the update is done, we can put the site back online if it was + // previously not in maintenance mode. + // \Drupal\system\Controller\DbUpdateController::batchFinished() will not + // unset maintenance mode if the site was in maintenance mode when the user + // was redirected to update.php by + // \Drupal\auto_updates\Controller\UpdateController::onFinish(). + if (!\Drupal::request()->getSession()->remove(static::MAINTENANCE_MODE_SESSION_KEY)) { + \Drupal::state()->set('system.maintenance_mode', FALSE); + } + } + +} diff --git a/core/modules/auto_updates/src/CommandExecutor.php b/core/modules/auto_updates/src/CommandExecutor.php new file mode 100644 index 000000000000..8af84954b89c --- /dev/null +++ b/core/modules/auto_updates/src/CommandExecutor.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\auto_updates; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Url; +use Drupal\package_manager\PathLocator; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Creates and starts `auto-update` terminal command processes. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class CommandExecutor { + + /** + * Constructs a CommandExecutor object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param string $appRoot + * The application root. + */ + public function __construct( + private readonly PathLocator $pathLocator, + private readonly FileSystemInterface $fileSystem, + private readonly TimeInterface $time, + private readonly string $appRoot + ) {} + + /** + * Creates a process to invoke the `auto-update` terminal command. + * + * @param string|null $arguments + * (optional) Additional arguments and/or options to append to the + * command line. + * + * @return \Symfony\Component\Process\Process + * A process to invoke the `auto-update` terminal command in a consistent + * way, with the `--host` and `--site-path` options always set. + */ + public function create(string $arguments = NULL): Process { + $script = $this->appRoot . '/core/scripts/auto-update'; + $command_line = implode(' ', [ + // Always run the command script directly through the PHP interpreter. + (new PhpExecutableFinder())->find(), + // Always pass the fully resolved script path to the interpreter. + $this->fileSystem->realpath($script), + // Always explicitly specify the base URI, which will allow this to work + // consistently in functional tests. + '--uri=' . Url::fromRoute('<front>')->setAbsolute()->toString(), + ]); + if ($arguments) { + $command_line .= " $arguments"; + } + + return Process::fromShellCommandline($command_line, $this->pathLocator->getProjectRoot()) + ->setTimeout(0); + } + + /** + * Starts a process and waits for it to have a process ID. + * + * This is meant to be used when starting a detached process, otherwise + * the current web request may end before the process has a chance to + * start. + * + * @param \Symfony\Component\Process\Process $process + * The process to start. + * @param int $timeout + * How long to wait for a process ID before giving up, in seconds. + * + * @return int|null + * The running process ID, or NULL if it didn't start after $timeout + * seconds. + */ + public function start(Process $process, int $timeout = 5): ?int { + $process->start(); + + $wait_until = $this->time->getCurrentTime() + $timeout; + do { + sleep(1); + $pid = $process->getPid(); + if ($pid) { + return $pid; + } + } while ($wait_until > $this->time->getCurrentTime()); + + return NULL; + } + +} diff --git a/core/modules/auto_updates/src/Commands/AutoUpdatesCommandBase.php b/core/modules/auto_updates/src/Commands/AutoUpdatesCommandBase.php new file mode 100644 index 000000000000..374f17880c9e --- /dev/null +++ b/core/modules/auto_updates/src/Commands/AutoUpdatesCommandBase.php @@ -0,0 +1,140 @@ +<?php + +namespace Drupal\auto_updates\Commands; + +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\StatusCheckMailer; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\DrupalKernel; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Base class for Automatic Updates console commands that boot Drupal. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +abstract class AutoUpdatesCommandBase extends Command { + + /** + * The I/O handler. + * + * @var \Symfony\Component\Console\Style\SymfonyStyle + */ + protected SymfonyStyle $io; + + /** + * The Drupal service container. + * + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + protected ContainerInterface $container; + + /** + * The console update stage. + * + * @var \Drupal\auto_updates\ConsoleUpdateStage + */ + protected ConsoleUpdateStage $stage; + + /** + * Constructs an AutoUpdatesCommandBase object. + * + * @param object $autoloader + * The autoloader, passed by reference so it can be decorated during + * Drupal's bootstrap process. + */ + public function __construct(private object &$autoloader) { + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void { + parent::configure(); + + $this->addOption('uri', mode: InputOption::VALUE_REQUIRED, description: 'The URI of the Drupal site, e.g. https://example.com or https://example.com/mysite.', default: 'https://default'); + $this->addOption('is-from-web', mode: InputOption::VALUE_NONE, description: 'This option is for internal use only and should not be passed.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->io = new SymfonyStyle($input, $output); + + // Detect the Drupal application root based on the location of the \Drupal + // class. + $drupal_root = dirname((new \ReflectionClass('\Drupal'))->getFileName(), 3); + // We need to be in the Drupal root for everything to boot up properly. + chdir($drupal_root); + + $uri = $input->getOption('uri'); + // If the --uri option did not include a scheme, prepend one. + if (parse_url($uri, PHP_URL_SCHEME) === NULL) { + $uri = 'https://' . $uri; + } + $base_path = parse_url($uri, PHP_URL_PATH) ?? '/'; + + // Ensure the SCRIPT_FILENAME and SCRIPT_NAME variables are accurate so that + // Drupal can generate URLs correctly. + $request = Request::create($uri, server: [ + 'SCRIPT_FILENAME' => $drupal_root . '/index.php', + 'SCRIPT_NAME' => $base_path . 'index.php', + ]); + + $kernel = DrupalKernel::createFromRequest($request, $this->autoloader, 'prod', app_root: $drupal_root) + ->boot(); + $kernel->preHandle($request); + $this->container = $kernel->getContainer(); + + $this->stage = $this->container->get(ConsoleUpdateStage::class); + $this->stage->output = $output; + $this->stage->isFromWeb = $input->getOption('is-from-web'); + + return static::SUCCESS; + } + + /** + * Runs status checks, and sends failure notifications if necessary. + */ + protected function runStatusChecks(): void { + assert($this->container instanceof ContainerInterface, 'Drupal is not booted.'); + + /** @var \Drupal\Component\Datetime\TimeInterface $time */ + $time = $this->container->get(TimeInterface::class); + /** @var \Drupal\auto_updates\Validation\StatusChecker $status_checker */ + $status_checker = $this->container->get(StatusChecker::class); + $last_results = $status_checker->getResults(); + $last_run_time = $status_checker->getLastRunTime(); + // Do not run status checks more than once an hour unless there are no + // results available. + $needs_run = $last_results === NULL || !$last_run_time || $time->getRequestTime() - $last_run_time > 3600; + + $settings = $this->container->get('config.factory') + ->get('auto_updates.settings') + ->get('unattended'); + + // To ensure consistent results, only run the status checks if we're + // explicitly configured to do unattended updates on the command line. + if ($needs_run && (($settings['method'] === 'web' && $this->stage->isFromWeb) || $settings['method'] === 'console')) { + // Only send failure notifications if unattended updates are enabled. + if ($settings['level'] !== CronUpdateRunner::DISABLED) { + $this->container->get(StatusCheckMailer::class) + ->sendFailureNotifications($last_results, $status_checker->run()->getResults()); + } + } + } + +} diff --git a/core/modules/auto_updates/src/Commands/PostApplyCommand.php b/core/modules/auto_updates/src/Commands/PostApplyCommand.php new file mode 100644 index 000000000000..e6c9f2c33cc0 --- /dev/null +++ b/core/modules/auto_updates/src/Commands/PostApplyCommand.php @@ -0,0 +1,55 @@ +<?php + +namespace Drupal\auto_updates\Commands; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ProjectInfo; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Finishes an automatic core update by running post-apply tasks. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. This command should not be called directly, + * and this class should not be used by external code. + */ +final class PostApplyCommand extends AutoUpdatesCommandBase { + + use StringTranslationTrait; + + /** + * {@inheritdoc} + */ + protected function configure(): void { + parent::configure(); + + $this + ->setName('post-apply') + // This command is for internal use only and should never be called + // directly. We don't want it to show up in the application command list. + ->setHidden() + ->addArgument('stage-id', InputArgument::REQUIRED); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + parent::execute($input, $output); + + $this->io->info((string) $this->t('Running post-apply tasks and final clean-up...')); + $this->stage->handlePostApply($input->getArgument('stage-id')); + + $message = $this->t('Drupal core was successfully updated to @version!', [ + '@version' => (new ProjectInfo('drupal'))->getInstalledVersion(), + ]); + $this->io->success((string) $message); + + $this->runStatusChecks(); + return static::SUCCESS; + } + +} diff --git a/core/modules/auto_updates/src/Commands/RunCommand.php b/core/modules/auto_updates/src/Commands/RunCommand.php new file mode 100644 index 000000000000..21692f308ef1 --- /dev/null +++ b/core/modules/auto_updates/src/Commands/RunCommand.php @@ -0,0 +1,107 @@ +<?php + +namespace Drupal\auto_updates\Commands; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Performs an automatic core update, if any updates are available. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. This command should not be called directly, + * and this class should not be used by external code. + */ +final class RunCommand extends AutoUpdatesCommandBase { + + use StringTranslationTrait; + + /** + * {@inheritdoc} + */ + protected function configure(): void { + parent::configure(); + + $this->setName('run') + ->setDescription('Automatically updates Drupal core, if automatic updates are enabled (via the update settings form) and any updates are available.') + // For simplicity, we want people to invoke the `auto-update` command with + // no arguments, so don't show this command in the command list. + ->setHidden(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + parent::execute($input, $output); + + $runner = $this->container->get(CronUpdateRunner::class); + if ($runner->getMode() === $runner::DISABLED) { + $message = $this->t('Automatic updates are disabled. Visit the update settings form at @url to enable them.', [ + '@url' => Url::fromRoute('update.settings')->toString(), + ]); + $this->io->error((string) $message); + return static::SUCCESS; + } + + $release = $this->stage->getTargetRelease(); + if ($release) { + $message = $this->t('Updating Drupal core to @version. This may take a while.', [ + '@version' => $release->getVersion(), + ]); + $this->io->info((string) $message); + $this->stage->performUpdate(); + } + else { + $this->io->info((string) $this->t('There is no Drupal core update available.')); + $this->runStatusChecks(); + } + + $this->processCleanupQueue(); + return static::SUCCESS; + } + + /** + * Processes the queue to delete defunct stage directories. + */ + private function processCleanupQueue(): void { + $verbose = $this->io->isVerbose(); + if ($verbose) { + $this->io->writeln((string) $this->t('Deleting unused stage directories...')); + } + + /** @var \Drupal\Core\Queue\QueueInterface $queue */ + $queue = $this->container->get(QueueFactory::class) + ->get('package_manager_cleanup'); + $worker = $this->container->get('plugin.manager.queue_worker') + ->createInstance('package_manager_cleanup'); + + $items_processed = 0; + while ($items_processed < 3 && ($item = $queue->claimItem())) { + $items_processed++; + + try { + $worker->processItem($item->data); + $queue->deleteItem($item); + if ($verbose) { + $message = (string) $this->t('Unused stage directory deleted: @dir', ['@dir' => $item->data]); + $this->io->writeln($message); + } + } + catch (\Throwable $e) { + $queue->releaseItem($item); + $message = (string) $this->t('Could not delete unused stage directory @dir due to exception: @message', [ + '@dir' => $item->data, + '@message' => $e->getMessage(), + ]); + $this->io->warning($message); + } + } + } + +} diff --git a/core/modules/auto_updates/src/ConsoleUpdateStage.php b/core/modules/auto_updates/src/ConsoleUpdateStage.php new file mode 100644 index 000000000000..e29c726e1f99 --- /dev/null +++ b/core/modules/auto_updates/src/ConsoleUpdateStage.php @@ -0,0 +1,342 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Lock\LockBackendInterface; +use Drupal\Core\Mail\MailManagerInterface; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\Core\Utility\Error; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\ApplyFailedException; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ProjectInfo; +use Drupal\update\ProjectRelease; +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\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * An updater that runs via a console command. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class ConsoleUpdateStage extends UpdateStage { + + /** + * {@inheritdoc} + */ + protected string $type = 'auto_updates:unattended'; + + /** + * The metadata key that stores the previous and target versions of core. + * + * @see ::handlePostApply() + * + * @var string + */ + protected const VERSIONS_METADATA_KEY = 'auto_updates_versions'; + + /** + * The console output handler. + * + * @see ::triggerPostApply() + * + * @var \Symfony\Component\Console\Output\OutputInterface + */ + public OutputInterface $output; + + /** + * Whether the update is being triggered by a web request. + * + * @see ::triggerPostApply() + * + * @var bool + */ + public bool $isFromWeb = FALSE; + + /** + * Constructs a ConsoleUpdateStage object. + * + * @param \Drupal\Core\Lock\LockBackendInterface $lock + * The lock service. + * @param \Drupal\auto_updates\CronUpdateRunner $cronUpdateRunner + * The cron update runner service. + * @param \Drupal\Core\Mail\MailManagerInterface $mailManager + * The mail manager service. + * @param \Drupal\auto_updates\StatusCheckMailer $statusCheckMailer + * The status check mailer service. + * @param \Drupal\auto_updates\ReleaseChooser $releaseChooser + * The cron release chooser service. + * @param \Drupal\auto_updates\CommandExecutor $commandExecutor + * The update command executor service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner + * The beginner service. + * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager + * The stager service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer + * The committer service. + * @param \Drupal\Core\Queue\QueueFactory $queueFactory + * The queue factory. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory + * The shared tempstore factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \Drupal\package_manager\FailureMarker $failureMarker + * The failure marker service. + */ + public function __construct( + private readonly LockBackendInterface $lock, + private readonly CronUpdateRunner $cronUpdateRunner, + private readonly MailManagerInterface $mailManager, + private readonly StatusCheckMailer $statusCheckMailer, + private readonly ReleaseChooser $releaseChooser, + private readonly CommandExecutor $commandExecutor, + ComposerInspector $composerInspector, + PathLocator $pathLocator, + BeginnerInterface $beginner, + StagerInterface $stager, + CommitterInterface $committer, + QueueFactory $queueFactory, + EventDispatcherInterface $eventDispatcher, + SharedTempStoreFactory $tempStoreFactory, + TimeInterface $time, + PathFactoryInterface $pathFactory, + FailureMarker $failureMarker, + ) { + parent::__construct($composerInspector, $pathLocator, $beginner, $stager, $committer, $queueFactory, $eventDispatcher, $tempStoreFactory, $time, $pathFactory, $failureMarker); + $this->output = new NullOutput(); + } + + /** + * Returns the release of Drupal core to update to, if any. + * + * @return \Drupal\update\ProjectRelease|null + * The release of Drupal core to which we will update, or NULL if there is + * nothing to update to. + */ + public function getTargetRelease(): ?ProjectRelease { + return $this->releaseChooser->getLatestInInstalledMinor($this); + } + + /** + * {@inheritdoc} + */ + final public function begin(array $project_versions, ?int $timeout = 300): never { + // Unattended updates should never be started using this method. They should + // only be done by ::performUpdate(), which has a strong opinion about which + // release to update to and will call ::setProcessStatus(). Throwing an + // exception here is just to enforce this boundary. To update to a specific + // version of core, use \Drupal\auto_updates\UpdateStage::begin() + // (which is called in::performUpdate() to start the update to the target + // version of core chosen by ::getTargetRelease()). + throw new \BadMethodCallException(__METHOD__ . '() cannot be called directly.'); + } + + /** + * Performs the update. + * + * @return bool + * Returns TRUE if any update was attempted, otherwise FALSE. + */ + public function performUpdate(): bool { + if ($this->cronUpdateRunner->getMode() === CronUpdateRunner::DISABLED) { + return FALSE; + } + + $next_release = $this->getTargetRelease(); + if (!$next_release) { + return FALSE; + } + $target_version = $next_release->getVersion(); + $project_info = new ProjectInfo('drupal'); + $update_started = FALSE; + + if (!$this->isAvailable()) { + if ($project_info->isInstalledVersionSafe() && !$this->isApplying()) { + $this->logger->notice('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.'); + return $update_started; + } + if (!$project_info->isInstalledVersionSafe() && $this->isApplying()) { + $this->logger->notice( + 'Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href="%url">update form</a>.', + ['%url' => Url::fromRoute('update.report_update')->setAbsolute()->toString()], + ); + return $update_started; + } + } + + // Delete the existing staging area if not available and the site is + // currently on an insecure version. + if (!$project_info->isInstalledVersionSafe() && !$this->isAvailable() && !$this->isApplying()) { + $destroy_message = $this->t('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.'); + $this->destroy(TRUE, $destroy_message); + $this->logger->notice($destroy_message->getUntranslatedString()); + } + + $installed_version = $project_info->getInstalledVersion(); + if (empty($installed_version)) { + $this->logger->error('Unable to determine the current version of Drupal core.'); + return $update_started; + } + if (!$this->lock->acquire('cron', 600)) { + $this->logger->error('Unable to start Drupal core update because cron is running.'); + return $update_started; + } + + // Do the bulk of the update in its own try-catch structure, so that we can + // handle any exceptions or validation errors consistently, and destroy the + // stage regardless of whether the update succeeds. + try { + $update_started = TRUE; + // @see ::begin() + $stage_id = parent::begin(['drupal' => $target_version]); + $this->setMetadata(static::VERSIONS_METADATA_KEY, [$installed_version, $target_version]); + $this->stage(); + $this->apply(); + } + catch (\Throwable $e) { + $this->lock->release('cron'); + + if ($e instanceof StageEventException && $e->event instanceof PreCreateEvent) { + // If the error happened during PreCreateEvent then the update did not + // really start. + $update_started = FALSE; + } + // Validation errors, or exceptions thrown by stage life cycle event + // listeners, were already logged by ::dispatch(), but we need to log + // exceptions that don't fall into those categories. + if (!$e instanceof StageEventException) { + Error::logException($this->logger, $e); + } + + // Send notifications about the failed update. + $mail_params = [ + 'previous_version' => $installed_version, + 'target_version' => $target_version, + 'error_message' => $e->getMessage(), + ]; + // Omit the backtrace in emails. That will be visible on the site, and is + // also stored in the failure marker. + if ($e instanceof StageFailureMarkerException || $e instanceof ApplyFailedException) { + $mail_params['error_message'] = $this->failureMarker->getMessage(FALSE); + } + if ($e instanceof ApplyFailedException) { + $mail_params['urgent'] = TRUE; + $key = 'cron_failed_apply'; + } + elseif (!$project_info->isInstalledVersionSafe()) { + $mail_params['urgent'] = TRUE; + $key = 'cron_failed_insecure'; + } + else { + $mail_params['urgent'] = FALSE; + $key = 'cron_failed'; + } + + foreach ($this->statusCheckMailer->getRecipients() as $email => $langcode) { + $this->mailManager->mail('auto_updates', $key, $email, $langcode, $mail_params); + } + + // If an error occurred during the pre-create event, the stage will be + // marked as available and we shouldn't try to destroy it, since the stage + // must be claimed in order to be destroyed. + if (!$this->isAvailable()) { + $this->destroy(); + } + return $update_started; + } + $this->triggerPostApply($stage_id); + return TRUE; + } + + /** + * Runs the post apply command. + * + * @param string $stage_id + * The ID of the current stage. + */ + protected function triggerPostApply(string $stage_id): void { + $arguments = sprintf('post-apply %s', $stage_id); + if ($this->isFromWeb) { + $arguments .= ' --is-from-web'; + } + // Run the post-apply command and pass its output to our output handler + // unmodified (hopefully including any ANSI color codes). + $output = $this->commandExecutor->create($arguments) + ->mustRun() + ->getOutput(); + $this->output->write($output); + } + + /** + * Runs post-apply tasks. + * + * @param string $stage_id + * The stage ID. + */ + public function handlePostApply(string $stage_id): void { + $owner = $this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY) + ->getOwnerId(); + // Reload the tempstore with the correct owner ID so we can claim the stage. + $this->tempStore = $this->tempStoreFactory->get('package_manager_stage', $owner); + + // This metadata was stored by ::performUpdate() after the update began. + [$installed_version, $target_version] = $this->claim($stage_id) + ->getMetadata(static::VERSIONS_METADATA_KEY); + + $this->logger->info('Drupal core has been updated from %previous_version to %target_version', [ + '%previous_version' => $installed_version, + '%target_version' => $target_version, + ]); + + // Send notifications about the successful update. + $mail_params = [ + 'previous_version' => $installed_version, + 'updated_version' => $target_version, + ]; + foreach ($this->statusCheckMailer->getRecipients() as $recipient => $langcode) { + $this->mailManager->mail('auto_updates', 'cron_successful', $recipient, $langcode, $mail_params); + } + + // Run post-apply tasks in their own try-catch block so that, if anything + // raises an exception, we'll log it and proceed to destroy the stage as + // soon as possible (which is also what we do in ::performUpdate()). + try { + $this->postApply(); + } + catch (StageEventException) { + // Validation errors, or exceptions caused by stage life cycle events, + // were already logged by ::dispatch(). + } + catch (\Throwable $e) { + Error::logException($this->logger, $e); + } + $this->lock->release('cron'); + $this->destroy(); + } + +} diff --git a/core/modules/auto_updates/src/Controller/StatusCheckController.php b/core/modules/auto_updates/src/Controller/StatusCheckController.php new file mode 100644 index 000000000000..0084089f54a9 --- /dev/null +++ b/core/modules/auto_updates/src/Controller/StatusCheckController.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Controller; + +use Drupal\auto_updates\Validation\ValidationResultDisplayTrait; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\Core\Controller\ControllerBase; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * A controller for running status checks. + * + * @internal + * Controller classes are internal. + */ +final class StatusCheckController extends ControllerBase { + + use ValidationResultDisplayTrait; + + /** + * Constructs a StatusCheckController object. + * + * @param \Drupal\auto_updates\Validation\StatusChecker $statusChecker + * The status checker service. + */ + public function __construct(private readonly StatusChecker $statusChecker) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get(StatusChecker::class), + ); + } + + /** + * Run the status checks. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect to the status report page. + */ + public function run(): RedirectResponse { + $results = $this->statusChecker->run()->getResults(); + if (!$results) { + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + // If there are no messages from the status checks, display a message that + // the site is ready. If there are messages, the status report will + // display them. + $this->messenger()->addStatus($this->t('No issues found. Your site is ready for automatic updates')); + } + else { + // Determine if any of the results are errors. + $error_results = $this->statusChecker->getResults(SystemManager::REQUIREMENT_ERROR); + // If there are any errors, display a failure message as an error. + // Otherwise, display it as a warning. + $severity = $error_results ? SystemManager::REQUIREMENT_ERROR : SystemManager::REQUIREMENT_WARNING; + $failure_message = $this->getFailureMessageForSeverity($severity); + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $this->messenger()->addError($failure_message); + } + else { + $this->messenger()->addWarning($failure_message); + } + } + // Set a redirect to the status report page. Any other page that provides a + // link to this controller should include 'destination' in the query string + // to ensure this redirect is overridden. + // @see \Drupal\Core\EventSubscriber\RedirectResponseSubscriber::checkRedirectUrl() + return $this->redirect('system.status'); + } + +} diff --git a/core/modules/auto_updates/src/Controller/UpdateController.php b/core/modules/auto_updates/src/Controller/UpdateController.php new file mode 100644 index 000000000000..4ac2b46886b7 --- /dev/null +++ b/core/modules/auto_updates/src/Controller/UpdateController.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Controller; + +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Url; +use Drupal\package_manager\Validator\PendingUpdatesValidator; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Defines a controller to handle various stages of an automatic update. + * + * @internal + * Controller classes are internal. + */ +final class UpdateController extends ControllerBase { + + /** + * Constructs an UpdateController object. + * + * @param \Drupal\package_manager\Validator\PendingUpdatesValidator $pendingUpdatesValidator + * The pending updates validator. + * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch + * The current route match. + * @param \Drupal\auto_updates\Validation\StatusChecker $statusChecker + * The status checker service. + */ + public function __construct( + private readonly PendingUpdatesValidator $pendingUpdatesValidator, + private readonly RouteMatchInterface $routeMatch, + private readonly StatusChecker $statusChecker, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get(PendingUpdatesValidator::class), + $container->get('current_route_match'), + $container->get(StatusChecker::class), + ); + } + + /** + * Redirects after staged changes are applied to the active directory. + * + * If there are any pending update hooks or post-updates, the user is sent to + * update.php to run those. Otherwise, they are redirected to the status + * report. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request object. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect to the appropriate destination. + */ + public function onFinish(Request $request): RedirectResponse { + if ($this->pendingUpdatesValidator->updatesExist()) { + // If there are pending database updates then the status checks will be + // run after the database updates are completed. + // @see \Drupal\auto_updates\BatchProcessor::dbUpdateBatchFinished + $message = $this->t('Apply database updates to complete the update process.'); + $url = Url::fromRoute('system.db_update'); + } + else { + $this->statusChecker->run(); + $message = $this->t('Update complete!'); + $url = Url::fromRoute('update.status'); + // Now that the update is done, we can put the site back online if it was + // previously not in maintenance mode. + if (!$request->getSession()->remove(BatchProcessor::MAINTENANCE_MODE_SESSION_KEY)) { + $this->state()->set('system.maintenance_mode', FALSE); + // @todo Remove once the core bug that shows the maintenance mode + // message after the site is out of maintenance mode is fixed in + // https://www.drupal.org/i/3279246. + $status_messages = $this->messenger()->messagesByType(MessengerInterface::TYPE_STATUS); + $status_messages = array_filter($status_messages, function (string $message) { + return !str_starts_with($message, (string) $this->t('Operating in maintenance mode.')); + }); + $this->messenger()->deleteByType(MessengerInterface::TYPE_STATUS); + foreach ($status_messages as $status_message) { + $this->messenger()->addStatus($status_message); + } + } + } + $this->messenger()->addStatus($message); + return new RedirectResponse($url->setAbsolute()->toString()); + } + +} diff --git a/core/modules/auto_updates/src/CronUpdateRunner.php b/core/modules/auto_updates/src/CronUpdateRunner.php new file mode 100644 index 000000000000..24821ebc698b --- /dev/null +++ b/core/modules/auto_updates/src/CronUpdateRunner.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\CronInterface; +use Drupal\Core\Utility\Error; +use Drupal\package_manager\PathLocator; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; + +/** + * Runs updates as a detached background process after regular cron tasks. + * + * The update process will be started in a detached process which will continue + * running after the web request has terminated. This is done after the + * decorated cron service has been called, so regular cron tasks will always be + * run regardless of whether there is an update available and whether an update + * is successful. + * + * @internal + * This class implements logic specific to Automatic Updates' cron hook + * implementation and may be changed or removed at any time without warning. + * It should not be called directly, and external code should not interact + * with it. + */ +class CronUpdateRunner implements CronInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + + /** + * The current interface between PHP and the server. + * + * @var string + */ + private static $serverApi = PHP_SAPI; + + /** + * All automatic updates are disabled. + * + * @var string + */ + public const DISABLED = 'disable'; + + /** + * Only perform automatic security updates. + * + * @var string + */ + public const SECURITY = 'security'; + + /** + * All automatic updates are enabled. + * + * @var string + */ + public const ALL = 'patch'; + + /** + * Constructs a CronUpdateRunner object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\CronInterface $inner + * The decorated cron service. + * @param \Drupal\auto_updates\CommandExecutor $commandExecutor + * The update command executor service. + */ + public function __construct( + private readonly ConfigFactoryInterface $configFactory, + private readonly PathLocator $pathLocator, + private readonly CronInterface $inner, + private readonly CommandExecutor $commandExecutor, + ) { + $this->setLogger(new NullLogger()); + } + + /** + * Runs the terminal update command. + */ + protected function runTerminalUpdateCommand(): void { + // Use the `&` on the command line to detach this process after it is + // started. This will allow the command to outlive the web request. + $process = $this->commandExecutor->create('--is-from-web &'); + + try { + $pid = $this->commandExecutor->start($process); + } + catch (\Throwable $throwable) { + Error::logException($this->logger, $throwable, 'Unable to start background update.'); + } + + if ($process->isTerminated()) { + if ($process->getExitCode() !== 0) { + $this->logger->error('Background update failed: %message', [ + '%message' => $process->getErrorOutput(), + ]); + } + } + elseif (empty($pid)) { + $this->logger->error('Background update failed because the process did not start within 5 seconds.'); + } + } + + /** + * Indicates if we are currently running at the command line. + * + * @return bool + * TRUE if we are running at the command line, otherwise FALSE. + */ + final public static function isCommandLine(): bool { + return self::$serverApi === 'cli'; + } + + /** + * {@inheritdoc} + */ + public function run() { + // Always run the cron service before we trigger the update terminal + // command. + $decorated_cron_succeeded = $this->inner->run(); + + $method = $this->configFactory->get('auto_updates.settings') + ->get('unattended.method'); + // If we are configured to run updates via the web, and we're actually being + // accessed via the web (i.e., anything that isn't the command line), go + // ahead and try to do the update. + if ($method === 'web' && !self::isCommandLine()) { + $this->runTerminalUpdateCommand(); + } + return $decorated_cron_succeeded; + } + + /** + * Gets the cron update mode. + * + * @return string + * The cron update mode. Will be one of the following constants: + * - self::DISABLED if updates during + * cron are entirely disabled. + * - self::SECURITY only security + * updates can be done during cron. + * - self::ALL if all updates are + * allowed during cron. + */ + final public function getMode(): string { + $mode = $this->configFactory->get('auto_updates.settings')->get('unattended.level'); + return $mode ?: static::SECURITY; + } + +} diff --git a/core/modules/auto_updates/src/Form/UpdateFormBase.php b/core/modules/auto_updates/src/Form/UpdateFormBase.php new file mode 100644 index 000000000000..91e652069113 --- /dev/null +++ b/core/modules/auto_updates/src/Form/UpdateFormBase.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Render\RendererInterface; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; + +/** + * Base class for update forms provided by Automatic Updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not extend this class. + */ +abstract class UpdateFormBase extends FormBase { + + use StatusCheckTrait; + + /** + * Adds a set of validation results to the messages. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * The validation results. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + */ + protected function displayResults(array $results, RendererInterface $renderer): void { + $severity = ValidationResult::getOverallSeverity($results); + + if ($severity === SystemManager::REQUIREMENT_OK) { + return; + } + + // Format the results as a single item list prefixed by a preamble message + // if necessary. + $build = [ + '#theme' => 'item_list__auto_updates_validation_results', + ]; + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $build['#prefix'] = $this->t('Your site cannot be automatically updated until further action is performed.'); + } + foreach ($results as $result) { + $messages = $result->messages; + + // If there's a summary, there's guaranteed to be at least one message, + // so render the result as a nested list. + $summary = $result->summary; + if ($summary) { + $build['#items'][] = [ + '#theme' => $build['#theme'], + '#prefix' => $summary, + '#items' => $messages, + ]; + } + else { + $build['#items'][] = reset($messages); + } + } + $message = $renderer->renderRoot($build); + + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $this->messenger()->addError($message); + } + else { + $this->messenger()->addWarning($message); + } + } + +} diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php new file mode 100644 index 000000000000..3336865ee9a0 --- /dev/null +++ b/core/modules/auto_updates/src/Form/UpdateReady.php @@ -0,0 +1,206 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Form; + +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\UpdateStage; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\ValidationResult; +use Drupal\Core\Batch\BatchBuilder; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Defines a form to commit staged updates. + * + * @internal + * Form classes are internal and the form structure may change at any time. + */ +final class UpdateReady extends UpdateFormBase { + + /** + * Constructs a new UpdateReady object. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher + * Event dispatcher service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + */ + public function __construct( + private readonly UpdateStage $stage, + private readonly StateInterface $state, + private readonly RendererInterface $renderer, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly ComposerInspector $composerInspector + ) {} + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'auto_updates_update_ready_form'; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get(UpdateStage::class), + $container->get('state'), + $container->get('renderer'), + $container->get('event_dispatcher'), + $container->get(ComposerInspector::class), + ); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, string $stage_id = NULL) { + try { + $this->stage->claim($stage_id); + } + catch (StageOwnershipException $e) { + $this->messenger()->addError($e->getMessage()); + return $form; + } + catch (StageFailureMarkerException $e) { + $this->messenger()->addError($e->getMessage()); + return $form; + } + + $messages = []; + + try { + $staged_core_packages = $this->composerInspector->getInstalledPackagesList($this->stage->getStageDirectory()) + ->getCorePackages() + ->getArrayCopy(); + } + catch (\Throwable) { + $messages[MessengerInterface::TYPE_ERROR][] = $this->t('There was an error loading the pending update. Press the <em>Cancel update</em> button to start over.'); + } + + // Don't set any messages if the form has been submitted, because we don't + // want them to be set during form submit. + if (!$form_state->getUserInput()) { + foreach ($messages as $type => $messages_of_type) { + foreach ($messages_of_type as $message) { + $this->messenger()->addMessage($message, $type); + } + } + } + + $form['actions'] = [ + 'cancel' => [ + '#type' => 'submit', + '#value' => $this->t('Cancel update'), + '#submit' => ['::cancel'], + ], + '#type' => 'actions', + ]; + $form['stage_id'] = [ + '#type' => 'value', + '#value' => $stage_id, + ]; + + if (empty($staged_core_packages)) { + return $form; + } + + $form['target_version'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('Drupal core will be updated to %version', [ + '%version' => reset($staged_core_packages)->version, + ]), + ]; + $form['backup'] = [ + '#prefix' => '<strong>', + '#markup' => $this->t('This cannot be undone, so it is strongly recommended to <a href=":url">back up your database and site</a> before continuing, if you haven\'t already.', [':url' => 'https://www.drupal.org/node/22281']), + '#suffix' => '</strong>', + ]; + if (!$this->state->get('system.maintenance_mode')) { + $form['maintenance_mode'] = [ + '#title' => $this->t('Perform updates with site in maintenance mode (strongly recommended)'), + '#type' => 'checkbox', + '#default_value' => TRUE, + ]; + } + + // Don't run the status checks once the form has been submitted. + if (!$form_state->getUserInput()) { + $results = $this->runStatusCheck($this->stage, $this->eventDispatcher); + // This will have no effect if $results is empty. + $this->displayResults($results, $this->renderer); + // If any errors occurred, return the form early so the user cannot + // continue. + if (ValidationResult::getOverallSeverity($results) === SystemManager::REQUIREMENT_ERROR) { + return $form; + } + } + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Continue'), + ]; + $form['actions']['submit']['#button_type'] = 'primary'; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Store maintenance_mode setting so we can restore it when done. + $this->getRequest() + ->getSession() + ->set(BatchProcessor::MAINTENANCE_MODE_SESSION_KEY, $this->state->get('system.maintenance_mode')); + + if ($form_state->getValue('maintenance_mode')) { + $this->state->set('system.maintenance_mode', TRUE); + } + + $stage_id = $form_state->getValue('stage_id'); + $batch = (new BatchBuilder()) + ->setTitle($this->t('Apply updates')) + ->setInitMessage($this->t('Preparing to apply updates')) + ->addOperation([BatchProcessor::class, 'commit'], [$stage_id]) + ->addOperation([BatchProcessor::class, 'postApply'], [$stage_id]) + ->addOperation([BatchProcessor::class, 'clean'], [$stage_id]) + ->setFinishCallback([BatchProcessor::class, 'finishCommit']) + ->toArray(); + + batch_set($batch); + } + + /** + * Cancels the in-progress update. + */ + public function cancel(array &$form, FormStateInterface $form_state): void { + try { + $this->stage->destroy(); + $this->messenger()->addStatus($this->t('The update was successfully cancelled.')); + $form_state->setRedirect('update.report_update'); + } + catch (StageException $e) { + $this->messenger()->addError($e->getMessage()); + } + } + +} diff --git a/core/modules/auto_updates/src/Form/UpdaterForm.php b/core/modules/auto_updates/src/Form/UpdaterForm.php new file mode 100644 index 000000000000..ab14f4ccaac7 --- /dev/null +++ b/core/modules/auto_updates/src/Form/UpdaterForm.php @@ -0,0 +1,458 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Form; + +use Drupal\auto_updates\BatchProcessor; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\ProjectInfo; +use Drupal\auto_updates\ReleaseChooser; +use Drupal\auto_updates\UpdateStage; +use Drupal\update\ProjectRelease; +use Drupal\Core\Batch\BatchBuilder; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; +use Drupal\update\UpdateManagerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Defines a form to update Drupal core. + * + * @internal + * Form classes are internal and the form structure may change at any time. + */ +final class UpdaterForm extends UpdateFormBase { + + /** + * Constructs a new UpdaterForm object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage service. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\auto_updates\ReleaseChooser $releaseChooser + * The release chooser service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + * @param \Drupal\package_manager\FailureMarker $failureMarker + * The failure marker service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + */ + public function __construct( + private readonly StateInterface $state, + private readonly UpdateStage $stage, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly ReleaseChooser $releaseChooser, + private readonly RendererInterface $renderer, + private readonly FailureMarker $failureMarker, + private readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'auto_updates_updater_form'; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('state'), + $container->get(UpdateStage::class), + $container->get('event_dispatcher'), + $container->get(ReleaseChooser::class), + $container->get('renderer'), + $container->get(FailureMarker::class), + $container->get('module_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + try { + $this->failureMarker->assertNotExists(); + } + catch (StageFailureMarkerException $e) { + $this->messenger()->addError($e->getMessage()); + return $form; + } + if ($this->stage->isAvailable()) { + $stage_exists = FALSE; + } + else { + $stage_exists = TRUE; + + // If there's a stage ID stored in the session, try to claim the stage + // with it. If we succeed, then an update is already in progress, and the + // current session started it, so redirect them to the confirmation form. + $stage_id = $this->getRequest()->getSession()->get(BatchProcessor::STAGE_ID_SESSION_KEY); + if ($stage_id) { + try { + $this->stage->claim($stage_id); + return $this->redirect('auto_updates.confirmation_page', [ + 'stage_id' => $stage_id, + ]); + } + catch (StageOwnershipException) { + // We already know a stage exists, even if it's not ours, so we don't + // have to do anything else here. + } + } + } + + $form['last_check'] = [ + '#theme' => 'update_last_check', + '#last' => $this->state->get('update.last_check', 0), + ]; + $project_info = new ProjectInfo('drupal'); + + $installed_version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion()); + try { + $support_branches = $project_info->getSupportedBranches(); + $releases = []; + foreach ($support_branches as $support_branch) { + $support_branch_extension_version = ExtensionVersion::createFromSupportBranch($support_branch); + if ($support_branch_extension_version->getMajorVersion() === $installed_version->getMajorVersion() && $support_branch_extension_version->getMinorVersion() >= $installed_version->getMinorVersion()) { + $recent_release_in_minor = $this->releaseChooser->getMostRecentReleaseInMinor($this->stage, $support_branch . '0'); + if ($recent_release_in_minor) { + $releases[$support_branch] = $recent_release_in_minor; + } + } + } + } + catch (\RuntimeException $e) { + $form['message'] = [ + '#markup' => $e->getMessage(), + ]; + return $form; + } + + if ($form_state->getUserInput() || $stage_exists) { + $results = []; + } + else { + try { + $results = $this->runStatusCheck($this->stage, $this->eventDispatcher); + } + catch (\Throwable $e) { + $this->messenger()->addError($e->getMessage()); + return $form; + } + } + $this->displayResults($results, $this->renderer); + $project = $project_info->getProjectInfo(); + if (empty($releases)) { + if ($project['status'] === UpdateManagerInterface::CURRENT) { + $this->messenger()->addMessage($this->t('No update available')); + } + else { + $message = $this->t('Updates were found, but they must be performed manually. See <a href=":url">the list of available updates</a> for more information.', [ + ':url' => Url::fromRoute('update.status')->toString(), + ]); + // If the current release is old, but otherwise secure and supported, + // this should be a regular status message. In any other case, urgent + // action is needed so flag it as an error. + $this->messenger()->addMessage($message, $project['status'] === UpdateManagerInterface::NOT_CURRENT ? MessengerInterface::TYPE_STATUS : MessengerInterface::TYPE_ERROR); + } + return $form; + } + + if (empty($project['title']) || empty($project['link'])) { + throw new \UnexpectedValueException('Expected project data to have a title and link.'); + } + + $form['title'] = [ + '#type' => 'html_tag', + '#tag' => 'h2', + '#value' => $this->t( + 'Update <a href=":url">Drupal core</a>', + [':url' => $project['link']], + ), + ]; + $form['current'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t( + 'Currently installed: @version (@status)', + [ + '@version' => $project_info->getInstalledVersion(), + '@status' => $this->getUpdateStatus($project['status']), + ] + ), + ]; + + switch ($project['status']) { + case UpdateManagerInterface::NOT_SECURE: + case UpdateManagerInterface::REVOKED: + $release_status = $this->t('Security update'); + $type = 'update-security'; + break; + + default: + $release_status = $this->t('Available update'); + $type = 'update-recommended'; + } + $create_update_buttons = !$stage_exists && ValidationResult::getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR; + + $installed_minor_release = FALSE; + $next_minor_release_count = 0; + foreach ($releases as $release) { + $release_version = ExtensionVersion::createFromVersionString($release->getVersion()); + if ($release_version->getMinorVersion() === $installed_version->getMinorVersion()) { + $installed_minor_release = TRUE; + $installed_version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion()); + $form['installed_minor'] = $this->createReleaseTable( + $release, + $release_status, + $this->t('Latest version of Drupal @major.@minor (currently installed):', [ + '@major' => $installed_version->getMajorVersion(), + '@minor' => $installed_version->getMinorVersion(), + ]), + $type, + $create_update_buttons, + // Any update in the current minor should be the primary update. + TRUE, + ); + } + else { + $next_minor_release_count++; + if ($next_minor_release_count === 1) { + if ($this->moduleHandler->moduleExists('help')) { + $url = Url::fromRoute('help.page') + ->setRouteParameter('name', 'auto_updates') + ->setOption('fragment', 'minor-update'); + + $form['minor_update_help'] = [ + '#markup' => $this->t('The following updates are in newer minor version of Drupal. <a href=":url">Learn more about updating to another minor version.</a>', [ + ':url' => $url->toString(), + ]), + '#prefix' => '<p>', + '#suffix' => '</p>', + ]; + } + } + // If there is no update in the current minor make the button for the + // next minor primary unless the project status is 'CURRENT' or + // 'NOT_CURRENT'. 'NOT_CURRENT' does not denote that installed version + // is not a valid only that there is newer version available. + if (!isset($is_primary)) { + $is_primary = !$installed_minor_release && !($project['status'] === UpdateManagerInterface::CURRENT || $project['status'] === UpdateManagerInterface::NOT_CURRENT); + } + else { + $is_primary = FALSE; + } + + // Since updating to another minor version of Drupal is more + // disruptive than updating within the currently installed minor + // version, ensure we display a link to the release notes for the + // first (x.y.0) release of the next minor version, which will inform + // site owners of any potential pitfalls or major changes. We should + // always be able to get release info for it; if we can't, that's an + // error condition. + $first_release_version = $release_version->getMajorVersion() . '.' . $release_version->getMinorVersion() . '.0'; + $available_updates = update_get_available(TRUE); + + // If the `.0` patch release of this minor is available link to its + // release notes because this will document the most important changes + // in this minor. + if (isset($available_updates['drupal']['releases'][$first_release_version])) { + $next_minor_first_release = ProjectRelease::createFromArray($available_updates['drupal']['releases'][$first_release_version]); + $caption = $this->t('Latest version of Drupal @major.@minor (next minor) (<a href=":url">Release notes</a>):', [ + '@major' => $release_version->getMajorVersion(), + '@minor' => $release_version->getMinorVersion(), + ':url' => $next_minor_first_release->getReleaseUrl(), + ]); + } + else { + $caption = $this->t('Latest version of Drupal @major.@minor (next minor):', [ + '@major' => $release_version->getMajorVersion(), + '@minor' => $release_version->getMinorVersion(), + ]); + } + + $form["next_minor_$next_minor_release_count"] = $this->createReleaseTable( + $release, + $installed_minor_release ? $this->t('Minor update') : $release_status, + $caption, + $installed_minor_release ? 'update-optional' : $type, + $create_update_buttons, + $is_primary + ); + } + } + + $form['backup'] = [ + '#markup' => $this->t('It\'s a good idea to <a href=":url">back up your database and site code</a> before you begin.', [':url' => 'https://www.drupal.org/node/22281']), + ]; + + if ($stage_exists) { + // If the form has been submitted, do not display this error message + // because ::deleteExistingUpdate() may run on submit. The message will + // still be displayed on form build if needed. + if (!$form_state->getUserInput()) { + $this->messenger()->addError($this->t('Cannot begin an update because another Composer operation is currently in progress.')); + } + $form['actions']['delete'] = [ + '#type' => 'submit', + '#value' => $this->t('Delete existing update'), + '#submit' => ['::deleteExistingUpdate'], + ]; + } + $form['actions']['#type'] = 'actions'; + + return $form; + } + + /** + * Submit function to delete an existing in-progress update. + */ + public function deleteExistingUpdate(): void { + try { + $this->stage->destroy(TRUE); + $this->messenger()->addMessage($this->t("Staged update deleted")); + } + catch (StageException $e) { + $this->messenger()->addError($e->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $button = $form_state->getTriggeringElement(); + $batch = (new BatchBuilder()) + ->setTitle($this->t('Downloading updates')) + ->setInitMessage($this->t('Preparing to download updates')) + ->addOperation( + [BatchProcessor::class, 'begin'], + [['drupal' => $button['#target_version']]] + ) + ->addOperation([BatchProcessor::class, 'stage']) + ->setFinishCallback([BatchProcessor::class, 'finishStage']) + ->toArray(); + + batch_set($batch); + } + + /** + * Gets the update table for a specific release. + * + * @param \Drupal\update\ProjectRelease $release + * The project release. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup $release_description + * The release description. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $caption + * The table caption, if any. + * @param string $update_type + * The update type. + * @param bool $create_update_button + * Whether the update button should be created. + * @param bool $is_primary + * Whether update button should be a primary button. + * + * @return string[][] + * The table render array. + */ + private function createReleaseTable(ProjectRelease $release, TranslatableMarkup $release_description, ?TranslatableMarkup $caption, string $update_type, bool $create_update_button, bool $is_primary): array { + $release_section = ['#type' => 'container']; + $release_section['table'] = [ + '#type' => 'table', + '#description' => $this->t('more'), + '#header' => [ + 'title' => [ + 'data' => $this->t('Update type'), + 'class' => ['update-project-name'], + ], + 'target_version' => [ + 'data' => $this->t('Version'), + ], + ], + ]; + if ($caption) { + $release_section['table']['#caption'] = $caption; + } + $release_section['table'][$release->getVersion()] = [ + 'title' => [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $release_description, + ], + 'target_version' => [ + 'data' => [ + '#type' => 'inline_template', + '#template' => '{{ release_version }} (<a href="{{ release_link }}" title="{{ project_title }}">{{ release_notes }}</a>)', + '#context' => [ + 'release_version' => $release->getVersion(), + 'release_link' => $release->getReleaseUrl(), + 'project_title' => $this->t( + 'Release notes for @project_title @version', + [ + '@project_title' => 'Drupal core', + '@version' => $release->getVersion(), + ] + ), + 'release_notes' => $this->t('Release notes'), + ], + ], + ], + '#attributes' => ['class' => ['update-' . $update_type]], + ]; + if ($create_update_button) { + $release_section['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Update to @version', ['@version' => $release->getVersion()]), + '#target_version' => $release->getVersion(), + ]; + if ($is_primary) { + $release_section['submit']['#button_type'] = 'primary'; + } + } + $release_section['#suffix'] = '<br />'; + return $release_section; + + } + + /** + * Gets the human-readable project status. + * + * @param int $status + * The project status, one of \Drupal\update\UpdateManagerInterface + * constants. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The human-readable status. + */ + private function getUpdateStatus(int $status): TranslatableMarkup { + return match ($status) { + UpdateManagerInterface::NOT_SECURE => $this->t('Security update required!'), + UpdateManagerInterface::REVOKED => $this->t('Revoked!'), + UpdateManagerInterface::NOT_SUPPORTED => $this->t('Not supported!'), + UpdateManagerInterface::NOT_CURRENT => $this->t('Update available'), + UpdateManagerInterface::CURRENT => $this->t('Up to date'), + default => $this->t('Unknown status'), + }; + } + +} diff --git a/core/modules/auto_updates/src/MaintenanceModeAwareCommitter.php b/core/modules/auto_updates/src/MaintenanceModeAwareCommitter.php new file mode 100644 index 000000000000..e5d661394d0d --- /dev/null +++ b/core/modules/auto_updates/src/MaintenanceModeAwareCommitter.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +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 Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Puts the site into maintenance mode while staged changes are applied. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class MaintenanceModeAwareCommitter implements CommitterInterface, EventSubscriberInterface { + + /** + * The state key which holds the original status of maintenance mode. + * + * @var string + */ + private const STATE_KEY = 'auto_updates.maintenance_mode'; + + /** + * Constructs a MaintenanceModeAwareCommitter object. + * + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $decorated + * The decorated committer service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct( + private readonly CommitterInterface $decorated, + private readonly StateInterface $state, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PostApplyEvent::class => ['restore', PHP_INT_MAX], + ]; + } + + /** + * Restores the original maintenance mode status after the update is applied. + * + * @param \Drupal\package_manager\Event\PostApplyEvent $event + * The event being handled. + */ + public function restore(PostApplyEvent $event): void { + if ($event->stage->getType() === 'auto_updates:unattended') { + $this->doRestore(); + } + } + + /** + * Restores the original maintenance mode status. + */ + private function doRestore(): void { + $this->state->set('system.maintenance_mode', $this->state->get(static::STATE_KEY)); + } + + /** + * {@inheritdoc} + */ + public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT,): void { + $this->state->set(static::STATE_KEY, $this->state->get('system.maintenance_mode', FALSE)); + $this->state->set('system.maintenance_mode', TRUE); + + try { + $this->decorated->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout); + } + catch (PreconditionException | InvalidArgumentException $e) { + $this->doRestore(); + + // Re-throw the exception, wrapped by another instance of itself. + $message = $e->getTranslatableMessage(); + $code = $e->getCode(); + // PreconditionException takes the failed precondition as its first + // argument. + if ($e instanceof PreconditionException) { + throw new PreconditionException($e->getPrecondition(), $message, $code, $e); + } + $class = get_class($e); + throw new $class($message, $code, $e); + } + } + +} diff --git a/core/modules/auto_updates/src/ReleaseChooser.php b/core/modules/auto_updates/src/ReleaseChooser.php new file mode 100644 index 000000000000..f2d0869540c0 --- /dev/null +++ b/core/modules/auto_updates/src/ReleaseChooser.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Composer\Semver\Semver; +use Drupal\auto_updates\Validator\VersionPolicyValidator; +use Drupal\package_manager\ProjectInfo; +use Drupal\update\ProjectRelease; +use Drupal\Core\Extension\ExtensionVersion; + +/** + * Defines a class to choose a release of Drupal core to update to. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ReleaseChooser { + + use VersionParsingTrait; + + /** + * The project information fetcher. + * + * @var \Drupal\package_manager\ProjectInfo + */ + private readonly ProjectInfo $projectInfo; + + /** + * Constructs an ReleaseChooser object. + * + * @param \Drupal\auto_updates\Validator\VersionPolicyValidator $versionPolicyValidator + * The version validator. + */ + public function __construct(private readonly VersionPolicyValidator $versionPolicyValidator) { + $this->projectInfo = new ProjectInfo('drupal'); + } + + /** + * Returns the releases that are installable. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage that will be used to install the releases. + * + * @return \Drupal\update\ProjectRelease[] + * The releases that are installable by the given update stage, according to + * the version validator service. + */ + private function getInstallableReleases(UpdateStage $stage): array { + $filter = function (string $version) use ($stage): bool { + return empty($this->versionPolicyValidator->validateVersion($stage, $version)); + }; + return array_filter( + $this->projectInfo->getInstallableReleases(), + $filter, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Gets the most recent release in the same minor as a specified version. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage that will be used to install the release. + * @param string $version + * The full semantic version number, which must include a patch version. + * + * @return \Drupal\update\ProjectRelease|null + * The most recent release in the minor if available, otherwise NULL. + * + * @throws \InvalidArgumentException + * If the given semantic version number does not contain a patch version. + */ + public function getMostRecentReleaseInMinor(UpdateStage $stage, string $version): ?ProjectRelease { + if (static::getPatchVersion($version) === NULL) { + throw new \InvalidArgumentException("The version number $version does not contain a patch version"); + } + $releases = $this->getInstallableReleases($stage); + foreach ($releases as $release) { + // Checks if the release is in the same minor as the currently installed + // version. For example, if the current version is 9.8.0 then the + // constraint ~9.8.0 (equivalent to >=9.8.0 && <9.9.0) will be used to + // check if the release is in the same minor. + if (Semver::satisfies($release->getVersion(), "~$version")) { + return $release; + } + } + return NULL; + } + + /** + * Gets the installed version of Drupal core. + * + * @return string + * The installed version of Drupal core. + */ + private function getInstalledVersion(): string { + return $this->projectInfo->getInstalledVersion(); + } + + /** + * Gets the latest release in the currently installed minor. + * + * This will only return a release if it passes the ::isValidVersion() method + * of the version validator service injected into this class. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage which will install the release. + * + * @return \Drupal\update\ProjectRelease|null + * The latest release in the currently installed minor, if any, otherwise + * NULL. + */ + public function getLatestInInstalledMinor(UpdateStage $stage): ?ProjectRelease { + return $this->getMostRecentReleaseInMinor($stage, $this->getInstalledVersion()); + } + + /** + * Gets the latest release in the next minor. + * + * This will only return a release if it passes the ::isValidVersion() method + * of the version validator service injected into this class. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage which will install the release. + * + * @return \Drupal\update\ProjectRelease|null + * The latest release in the next minor, if any, otherwise NULL. + */ + public function getLatestInNextMinor(UpdateStage $stage): ?ProjectRelease { + $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion()); + $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0'; + return $this->getMostRecentReleaseInMinor($stage, $next_minor); + } + +} diff --git a/core/modules/auto_updates/src/Routing/RouteSubscriber.php b/core/modules/auto_updates/src/Routing/RouteSubscriber.php new file mode 100644 index 000000000000..e237e83e03a3 --- /dev/null +++ b/core/modules/auto_updates/src/Routing/RouteSubscriber.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Routing; + +use Drupal\auto_updates\Form\UpdaterForm; +use Drupal\Core\Routing\RouteSubscriberBase; +use Drupal\Core\Routing\RoutingEvents; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * Modifies route definitions. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class RouteSubscriber extends RouteSubscriberBase { + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + // Try to run after other route subscribers, to minimize the chances of + // conflicting with other code that is modifying Update module routes. + RoutingEvents::ALTER => ['onAlterRoutes', -1000], + ]; + } + + /** + * {@inheritdoc} + */ + protected function alterRoutes(RouteCollection $collection) { + // Disable status checks on certain routes. + $disabled_routes = [ + 'system.theme_install', + 'update.module_install', + 'update.status', + 'update.report_install', + 'system.status', + 'update.confirmation_page', + 'system.batch_page.html', + ]; + foreach ($disabled_routes as $route) { + $route = $collection->get($route); + if ($route) { + $route->setOption('_auto_updates_status_messages', 'skip'); + } + } + + // Take over the routes defined by the core Update module. + $update_module_routes = [ + 'update.report_update', + 'update.module_update', + 'update.theme_update', + ]; + $defaults = [ + '_form' => UpdaterForm::class, + '_title' => 'Update', + ]; + // Completely redefine the access requirements to disable incompatible + // requirements defined on the core routes, like `_access_update_manager`, + // which would allow access to our forms if the `allow_authorize_operations` + // setting is enabled. + $requirements = [ + '_permission' => 'administer software updates', + ]; + $options = [ + '_admin_route' => TRUE, + '_maintenance_access' => TRUE, + '_auto_updates_status_messages' => 'skip', + ]; + foreach ($update_module_routes as $name) { + $route = $collection->get($name); + if ($route) { + $collection->add($name, new Route($route->getPath(), $defaults, $requirements, $options)); + } + } + } + +} diff --git a/core/modules/auto_updates/src/StatusCheckMailer.php b/core/modules/auto_updates/src/StatusCheckMailer.php new file mode 100644 index 000000000000..a7cccbdf5e21 --- /dev/null +++ b/core/modules/auto_updates/src/StatusCheckMailer.php @@ -0,0 +1,176 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Mail\MailManagerInterface; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; + +/** + * Defines a service to send status check failure emails during cron. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class StatusCheckMailer { + + /** + * Never send failure notifications. + * + * @var string + */ + public const DISABLED = 'disabled'; + + /** + * Send failure notifications if status checks raise any errors or warnings. + * + * @var string + */ + public const ALL = 'all'; + + /** + * Only send failure notifications if status checks raise errors. + * + * @var string + */ + public const ERRORS_ONLY = 'errors_only'; + + /** + * Constructs a StatusCheckNotifier object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \Drupal\Core\Mail\MailManagerInterface $mailManager + * The mail manager service. + * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager + * The language manager service. + */ + public function __construct( + private readonly ConfigFactoryInterface $configFactory, + private readonly MailManagerInterface $mailManager, + private readonly LanguageManagerInterface $languageManager, + ) {} + + /** + * Sends status check failure notifications if necessary. + * + * Notifications will only be sent if the following conditions are fulfilled: + * - Notifications are enabled. + * - If we are configured to only send notifications if there are errors, the + * current result set must contain at least one error result. + * - The previous and current result sets, after filtering, are different. + * + * @param \Drupal\package_manager\ValidationResult[]|null $previous_results + * The previous set of status check results, if any. + * @param \Drupal\package_manager\ValidationResult[] $current_results + * The current set of status check results. + */ + public function sendFailureNotifications(?array $previous_results, array $current_results): void { + $level = $this->configFactory->get('auto_updates.settings') + ->get('status_check_mail'); + + if ($level === static::DISABLED) { + return; + } + // If we're ignoring warnings, filter them out of the previous and current + // result sets. + elseif ($level === static::ERRORS_ONLY) { + $filter = function (ValidationResult $result): bool { + return $result->severity === SystemManager::REQUIREMENT_ERROR; + }; + $current_results = array_filter($current_results, $filter); + // If the current results don't have any errors, there's nothing else + // for us to do. + if (empty($current_results)) { + return; + } + + if ($previous_results) { + $previous_results = array_filter($previous_results, $filter); + } + } + + if ($this->resultsAreDifferent($previous_results, $current_results)) { + foreach ($this->getRecipients() as $email => $langcode) { + $this->mailManager->mail('auto_updates', 'status_check_failed', $email, $langcode, []); + } + } + } + + /** + * Determines if two sets of validation results are different. + * + * @param \Drupal\package_manager\ValidationResult[]|null $previous_results + * The previous set of validation results, if any. + * @param \Drupal\package_manager\ValidationResult[] $current_results + * The current set of validation results. + * + * @return bool + * TRUE if the given result sets are different; FALSE otherwise. + */ + private function resultsAreDifferent(?array $previous_results, array $current_results): bool { + if ($previous_results === NULL || count($previous_results) !== count($current_results)) { + return TRUE; + } + + // We can't rely on the previous and current result sets being in the same + // order, so we need to use this inefficient nested loop to check if each + // previous result is anywhere in the current result set. This is a case + // where accuracy is probably more important than performance. + $result_previously_existed = function (ValidationResult $result) use ($previous_results): bool { + foreach ($previous_results as $previous_result) { + if (ValidationResult::isEqual($result, $previous_result)) { + return TRUE; + } + } + return FALSE; + }; + foreach ($current_results as $result) { + if (!$result_previously_existed($result)) { + return TRUE; + } + } + return FALSE; + } + + /** + * Returns an array of people to email. + * + * @return string[] + * An array whose keys are the email addresses to send notifications to, and + * values are the langcodes that they should be emailed in. + */ + public function getRecipients(): array { + $recipients = $this->configFactory->get('update.settings') + ->get('notification.emails'); + $emails = []; + foreach ($recipients as $recipient) { + $emails[$recipient] = $this->getEmailLangcode($recipient); + } + return $emails; + } + + /** + * Retrieves preferred language to send email. + * + * @param string $recipient + * The email address of the recipient. + * + * @return string + * The preferred language of the recipient. + */ + private function getEmailLangcode(string $recipient): string { + $user = user_load_by_mail($recipient); + if ($user) { + return $user->getPreferredLangcode(); + } + return $this->languageManager->getDefaultLanguage()->getId(); + } + +} diff --git a/core/modules/auto_updates/src/UpdateStage.php b/core/modules/auto_updates/src/UpdateStage.php new file mode 100644 index 000000000000..c205ac64b665 --- /dev/null +++ b/core/modules/auto_updates/src/UpdateStage.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use Drupal\package_manager\ComposerInspector; +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\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Defines a service to perform updates. + * + * Currently, only updates to Drupal core are supported. This is done by + * changing the constraint for either 'drupal/core' or 'drupal/core-recommended' + * in the project-level composer.json. If neither package is directly required + * in the project-level composer.json, a requirement will be added. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +class UpdateStage extends StageBase { + + /** + * {@inheritdoc} + */ + protected string $type = 'auto_updates:attended'; + + /** + * Constructs a new UpdateStage object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner + * The beginner service. + * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager + * The stager service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer + * The committer service. + * @param \Drupal\Core\Queue\QueueFactory $queueFactory + * The queue factory. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory + * The shared tempstore factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \Drupal\package_manager\FailureMarker $failureMarker + * The failure marker service. + */ + public function __construct( + protected readonly ComposerInspector $composerInspector, + PathLocator $pathLocator, + BeginnerInterface $beginner, + StagerInterface $stager, + CommitterInterface $committer, + QueueFactory $queueFactory, + EventDispatcherInterface $eventDispatcher, + SharedTempStoreFactory $tempStoreFactory, + TimeInterface $time, + PathFactoryInterface $pathFactory, + FailureMarker $failureMarker, + ) { + parent::__construct($pathLocator, $beginner, $stager, $committer, $queueFactory, $eventDispatcher, $tempStoreFactory, $time, $pathFactory, $failureMarker); + } + + /** + * Begins the update. + * + * @param string[] $project_versions + * The versions of the packages to update to, keyed by package name. + * @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 + * The unique ID of the stage. + * + * @throws \InvalidArgumentException + * Thrown if no project version for Drupal core is provided. + */ + public function begin(array $project_versions, ?int $timeout = 300): string { + if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) { + throw new \InvalidArgumentException("Currently only updates to Drupal core are supported."); + } + + $package_versions = [ + 'production' => [], + 'dev' => [], + ]; + + $project_root = $this->pathLocator->getProjectRoot(); + $info = $this->composerInspector->getRootPackageInfo($project_root); + foreach ($this->composerInspector->getInstalledPackagesList($project_root)->getCorePackages() as $package) { + $group = isset($info['devRequires'][$package->name]) ? 'dev' : 'production'; + $package_versions[$group][$package->name] = $project_versions['drupal']; + } + + // Ensure that package versions are available to pre-create event + // subscribers. We can't use ::setMetadata() here because it requires the + // stage to be claimed, but that only happens during ::create(). + $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [ + 'packages' => $package_versions, + ]); + return $this->create($timeout); + } + + /** + * Returns the package versions that will be required during the update. + * + * @return string[][] + * An array with two sub-arrays: 'production' and 'dev'. Each is a set of + * package versions, where the keys are package names and the values are + * version constraints understood by Composer. + */ + public function getPackageVersions(): array { + return $this->getMetadata('packages'); + } + + /** + * Stages the update. + */ + public function stage(?int $timeout = 300): void { + $this->checkOwnership(); + + // Convert an associative array of package versions, keyed by name, to + // command-line arguments in the form `vendor/name:version`. + $map = function (array $versions): array { + $requirements = []; + foreach ($versions as $package => $version) { + $requirements[] = "$package:$version"; + } + return $requirements; + }; + $versions = array_map($map, $this->getPackageVersions()); + $this->require($versions['production'], $versions['dev'], $timeout); + } + + /** + * {@inheritdoc} + */ + protected function getFailureMarkerMessage(): TranslatableMarkup { + return $this->t('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.'); + } + +} diff --git a/core/modules/auto_updates/src/Validation/AdminStatusCheckMessages.php b/core/modules/auto_updates/src/Validation/AdminStatusCheckMessages.php new file mode 100644 index 000000000000..20c3b247af46 --- /dev/null +++ b/core/modules/auto_updates/src/Validation/AdminStatusCheckMessages.php @@ -0,0 +1,224 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validation; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Messenger\MessengerTrait; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\AdminContext; +use Drupal\Core\Routing\CurrentRouteMatch; +use Drupal\Core\Routing\RedirectDestinationTrait; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Class for displaying status check results on admin pages. + * + * @internal + * This class implements logic to output the messages from status checkers + * on admin pages. It should not be called directly. + */ +final class AdminStatusCheckMessages implements ContainerInjectionInterface { + + use MessengerTrait; + use StringTranslationTrait; + use RedirectDestinationTrait; + use ValidationResultDisplayTrait; + + /** + * Constructs an AdminStatusCheckMessages object. + * + * @param \Drupal\auto_updates\Validation\StatusChecker $statusChecker + * The status checker service. + * @param \Drupal\Core\Routing\AdminContext $adminContext + * The admin context service. + * @param \Drupal\Core\Session\AccountProxyInterface $currentUser + * The current user. + * @param \Drupal\Core\Routing\CurrentRouteMatch $currentRouteMatch + * The current route match. + * @param \Drupal\auto_updates\CronUpdateRunner $runner + * The cron update runner service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + private readonly StatusChecker $statusChecker, + private readonly AdminContext $adminContext, + private readonly AccountProxyInterface $currentUser, + private readonly CurrentRouteMatch $currentRouteMatch, + private readonly CronUpdateRunner $runner, + private readonly RendererInterface $renderer, + private readonly ConfigFactoryInterface $configFactory + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get(StatusChecker::class), + $container->get('router.admin_context'), + $container->get('current_user'), + $container->get('current_route_match'), + $container->get(CronUpdateRunner::class), + $container->get('renderer'), + $container->get('config.factory') + ); + } + + /** + * Displays the checker results messages on admin pages. + */ + public function displayAdminPageMessages(): void { + if (!$this->displayResultsOnCurrentPage()) { + return; + } + if ($this->statusChecker->getResults() === NULL) { + $method = $this->configFactory->get('auto_updates.settings') + ->get('unattended.method'); + + $checker_url = Url::fromRoute('auto_updates.status_check')->setOption('query', $this->getDestinationArray()); + if ($method === 'web' && $checker_url->access()) { + $this->messenger()->addError($this->t('Your site has not recently run an update readiness check. <a href=":url">Rerun readiness checks now.</a>', [ + ':url' => $checker_url->toString(), + ])); + } + elseif ($method === 'console') { + // @todo Link to the documentation on how to set up unattended updates + // via the terminal in https://drupal.org/i/3362695. + $message = $this->t('Unattended updates are configured to run via the console, but not appear to have run recently.'); + $this->messenger()->addError($message); + } + } + else { + // Display errors, if there are any. If there aren't, then display + // warnings, if there are any. + if (!$this->displayResultsForSeverity(SystemManager::REQUIREMENT_ERROR)) { + $this->displayResultsForSeverity(SystemManager::REQUIREMENT_WARNING); + } + } + } + + /** + * Determines whether the messages should be displayed on the current page. + * + * @return bool + * Whether the messages should be displayed on the current page. + */ + private function displayResultsOnCurrentPage(): bool { + // If updates will not run during cron then we don't need to show the + // status checks on admin pages. + if ($this->runner->getMode() === CronUpdateRunner::DISABLED) { + return FALSE; + } + + if ($this->adminContext->isAdminRoute() && $this->currentUser->hasPermission('administer site configuration')) { + return $this->currentRouteMatch->getRouteObject() + ?->getOption('_auto_updates_status_messages') !== 'skip'; + } + return FALSE; + } + + /** + * Displays the results for severity. + * + * @param int $severity + * The severity for the results to display. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return bool + * Whether any results were displayed. + */ + private function displayResultsForSeverity(int $severity): bool { + $results = $this->statusChecker->getResults($severity); + if (empty($results)) { + return FALSE; + } + $this->displayResults($results, $this->messenger(), $this->renderer); + return TRUE; + } + + /** + * Displays the result summary. + */ + public function displayResultSummary(): void { + if (!$this->currentUser->hasPermission('administer site configuration')) { + return; + } + $results = $this->statusChecker->getResults(); + if (empty($results)) { + return; + } + // First message: severity. + $overall_severity = ValidationResult::getOverallSeverity($results); + $message = $this->getFailureMessageForSeverity($overall_severity); + $message_type = $overall_severity === SystemManager::REQUIREMENT_ERROR ? MessengerInterface::TYPE_ERROR : MessengerInterface::TYPE_WARNING; + $this->messenger()->addMessage($message, $message_type); + + // Optional second message: more details (for users with sufficient + // permissions). + $status_report_url = Url::fromRoute('system.status'); + if ($status_report_url->access()) { + $this->messenger()->addMessage( + $this->t('<a href=":url">See status report for more details.</a>', [ + ':url' => $status_report_url->toString(), + ]), + $message_type, + ); + } + } + + /** + * Adds a set of validation results to the messages. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * The validation results. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + */ + private function displayResults(array $results, MessengerInterface $messenger, RendererInterface $renderer): void { + $severity = ValidationResult::getOverallSeverity($results); + + if ($severity === SystemManager::REQUIREMENT_OK) { + return; + } + + // Display a single message for each validation result, even if it has + // multiple messages. This is because, on regular admin pages, we merely + // want to alert users that problems exist, but not burden them with the + // details. They can get those on the status report and updater form. + $format_result = function (ValidationResult $result): TranslatableMarkup|string { + $messages = $result->messages; + return $result->summary ?: reset($messages); + }; + // Format the results as a single item list prefixed by a preamble message. + $build = [ + '#theme' => 'item_list__auto_updates_validation_results', + '#prefix' => $this->getFailureMessageForSeverity($severity), + '#items' => array_map($format_result, $results), + ]; + $message = $renderer->renderRoot($build); + + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $messenger->addError($message); + } + else { + $messenger->addWarning($message); + } + } + +} diff --git a/core/modules/auto_updates/src/Validation/StatusCheckRequirements.php b/core/modules/auto_updates/src/Validation/StatusCheckRequirements.php new file mode 100644 index 000000000000..d186c8b437c7 --- /dev/null +++ b/core/modules/auto_updates/src/Validation/StatusCheckRequirements.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validation; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Class for generating the status checkers' output for hook_requirements(). + * + * @see auto_updates_requirements() + * + * @internal + * This class implements logic to output the messages from status checkers + * on the status report page. It should not be called directly. + */ +final class StatusCheckRequirements implements ContainerInjectionInterface { + + use StringTranslationTrait; + use ValidationResultDisplayTrait; + + /** + * Constructs a StatusCheckRequirements object. + * + * @param \Drupal\auto_updates\Validation\StatusChecker $statusChecker + * The status checker service. + * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter + * The date formatter service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + private readonly StatusChecker $statusChecker, + private readonly DateFormatterInterface $dateFormatter, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get(StatusChecker::class), + $container->get('date.formatter'), + $container->get('config.factory'), + ); + } + + /** + * Returns the method used to run unattended updates. + * + * @return string + * The method used to run unattended updates. Will be either 'console' or + * 'web'. + */ + private function getMethod(): string { + return $this->configFactory->get('auto_updates.settings') + ->get('unattended.method'); + } + + /** + * Gets requirements arrays as specified in hook_requirements(). + * + * @return mixed[] + * Requirements arrays as specified by hook_requirements(). + */ + public function getRequirements(): array { + $requirements = []; + + $results = $this->statusChecker->getResults(); + // If unattended updates are run on the terminal, we don't want to do the + // status check right now, since running them over the web may yield + // inaccurate or irrelevant results. The console command runs status checks, + // so if there are no results, we can assume it has not been run in a while, + // and raise an error about that. + if (is_null($results) && $this->getMethod() === 'console') { + $requirements['auto_updates_status_check_console_command_not_run'] = [ + 'title' => $this->t('Update readiness checks'), + 'severity' => SystemManager::REQUIREMENT_ERROR, + // @todo Link to the documentation on how to set up unattended updates + // via the terminal in https://drupal.org/i/3362695. + 'value' => $this->t('Unattended updates are configured to run via the console, but do not appear to have run recently.'), + ]; + return $requirements; + } + + $results ??= $this->statusChecker->run()->getResults(); + if (empty($results)) { + $requirements['auto_updates_status_check'] = [ + 'title' => $this->t('Update readiness checks'), + 'severity' => SystemManager::REQUIREMENT_OK, + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + 'value' => $this->t('Your site is ready for automatic updates.'), + ]; + $run_link = $this->createRunLink(); + if ($run_link) { + $requirements['auto_updates_status_check']['description'] = $run_link; + } + } + else { + foreach ([SystemManager::REQUIREMENT_WARNING, SystemManager::REQUIREMENT_ERROR] as $severity) { + if ($requirement = $this->createRequirementForSeverity($severity)) { + $requirements["auto_updates_status_$severity"] = $requirement; + } + } + } + return $requirements; + } + + /** + * Creates a requirement for checker results of a specific severity. + * + * @param int $severity + * The severity for requirement. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return mixed[]|null + * Requirements array as specified by hook_requirements(), or NULL + * if no requirements can be determined. + */ + private function createRequirementForSeverity(int $severity): ?array { + $severity_messages = []; + $results = $this->statusChecker->getResults($severity); + if (!$results) { + return NULL; + } + foreach ($results as $result) { + $checker_messages = $result->messages; + $summary = $result->summary; + if (empty($summary)) { + $severity_messages[] = ['#markup' => array_pop($checker_messages)]; + } + else { + $severity_messages[] = [ + '#type' => 'details', + '#title' => $summary, + '#open' => FALSE, + 'messages' => [ + '#theme' => 'item_list', + '#items' => $checker_messages, + ], + ]; + } + } + $requirement = [ + 'title' => $this->t('Update readiness checks'), + 'severity' => $severity, + 'value' => $this->getFailureMessageForSeverity($severity), + 'description' => [ + 'messages' => [ + '#theme' => 'item_list', + '#items' => $severity_messages, + ], + ], + ]; + if ($run_link = $this->createRunLink()) { + $requirement['description']['run_link'] = [ + '#type' => 'container', + '#markup' => $run_link, + ]; + } + return $requirement; + } + + /** + * Creates a link to run the status checks. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null + * A link, if the user has access to run the status checks, otherwise + * NULL. + */ + private function createRunLink(): ?TranslatableMarkup { + // Only show this link if unattended updates are being run over the web. + if ($this->getMethod() !== 'web') { + return NULL; + } + + $status_check_url = Url::fromRoute('auto_updates.status_check'); + if ($status_check_url->access()) { + return $this->t( + '<a href=":link">Rerun readiness checks</a> now.', + [':link' => $status_check_url->toString()] + ); + } + return NULL; + } + +} diff --git a/core/modules/auto_updates/src/Validation/StatusChecker.php b/core/modules/auto_updates/src/Validation/StatusChecker.php new file mode 100644 index 000000000000..0a7230df9caa --- /dev/null +++ b/core/modules/auto_updates/src/Validation/StatusChecker.php @@ -0,0 +1,187 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validation; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\StatusCheckMailer; +use Drupal\Core\Config\ConfigCrudEvent; +use Drupal\Core\Config\ConfigEvents; +use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\auto_updates\UpdateStage; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Runs status checks and caches the results. + */ +final class StatusChecker implements EventSubscriberInterface { + + use StatusCheckTrait; + + /** + * The key/value expirable storage. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface + */ + private readonly KeyValueStoreExpirableInterface $keyValueExpirable; + + /** + * Constructs a StatusChecker. + * + * @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory + * The key/value expirable factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\auto_updates\UpdateStage $updateStage + * The update stage service. + * @param \Drupal\auto_updates\ConsoleUpdateStage $consoleUpdateStage + * The console update stage service. + * @param \Drupal\auto_updates\CronUpdateRunner $cronUpdateRunner + * The cron update runner service. + * @param int $resultsTimeToLive + * The number of hours to store results. + */ + public function __construct( + KeyValueExpirableFactoryInterface $key_value_expirable_factory, + private readonly TimeInterface $time, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly UpdateStage $updateStage, + private readonly ConsoleUpdateStage $consoleUpdateStage, + private readonly CronUpdateRunner $cronUpdateRunner, + private readonly int $resultsTimeToLive, + ) { + $this->keyValueExpirable = $key_value_expirable_factory->get('auto_updates'); + } + + /** + * Dispatches the status check event and stores the results. + * + * @return $this + */ + public function run(): self { + // If updates will run during cron, use the console update stage service + // provided by this module. This will allow validators to run specific + // validation for conditions that only affect cron updates. + if ($this->cronUpdateRunner->getMode() === CronUpdateRunner::DISABLED) { + $stage = $this->updateStage; + } + else { + $stage = $this->consoleUpdateStage; + } + $results = $this->runStatusCheck($stage, $this->eventDispatcher); + + $this->keyValueExpirable->setWithExpire( + 'status_check_last_run', + $results, + $this->resultsTimeToLive * 60 * 60 + ); + $this->keyValueExpirable->set('status_check_timestamp', $this->time->getRequestTime()); + return $this; + } + + /** + * Dispatches the status check event if there no stored valid results. + * + * @return $this + * + * @see self::getResults() + */ + public function runIfNoStoredResults(): self { + if ($this->getResults() === NULL) { + $this->run(); + } + return $this; + } + + /** + * Gets the validation results from the last run. + * + * @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[]|null + * The validation result objects or NULL if no results are + * available or if the stored results are no longer valid. + */ + public function getResults(?int $severity = NULL): ?array { + $results = $this->keyValueExpirable->get('status_check_last_run'); + if ($results !== NULL) { + if ($severity !== NULL) { + $results = array_filter($results, function ($result) use ($severity) { + return $result->severity === $severity; + }); + } + return $results; + } + return NULL; + } + + /** + * Deletes any stored status check results. + */ + public function clearStoredResults(): void { + $this->keyValueExpirable->delete('status_check_last_run'); + } + + /** + * Gets the timestamp of the last run. + * + * @return int|null + * The timestamp of the last completed run, or NULL if no run has + * been completed. + */ + public function getLastRunTime(): ?int { + return $this->keyValueExpirable->get('status_check_timestamp'); + } + + /** + * Reacts when config is saved. + * + * @param \Drupal\Core\Config\ConfigCrudEvent $event + * The event object. + */ + public function onConfigSave(ConfigCrudEvent $event): void { + $config = $event->getConfig(); + + // If the path of the Composer executable has changed, the status check + // results are likely to change as well. + if ($config->getName() === 'package_manager.settings' && $event->isChanged('executables.composer')) { + $this->clearStoredResults(); + } + elseif ($config->getName() === 'auto_updates.settings') { + // If anything about how we run unattended updates has changed, clear the + // stored results, since they can be affected by these settings. + if ($event->isChanged('unattended')) { + $this->clearStoredResults(); + } + // We only send status check failure notifications if unattended updates + // are enabled. If notifications were previously disabled but have been + // re-enabled, or their sensitivity level has changed, clear the stored + // results so that we'll send accurate notifications next time cron runs. + elseif ($event->isChanged('status_check_mail') && $config->get('status_check_mail') !== StatusCheckMailer::DISABLED) { + $this->clearStoredResults(); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PostApplyEvent::class => 'clearStoredResults', + ConfigEvents::SAVE => 'onConfigSave', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validation/ValidationResultDisplayTrait.php b/core/modules/auto_updates/src/Validation/ValidationResultDisplayTrait.php new file mode 100644 index 000000000000..6501608adeb3 --- /dev/null +++ b/core/modules/auto_updates/src/Validation/ValidationResultDisplayTrait.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validation; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; + +/** + * Common methods for displaying validation results in the admin UI. + * + * @internal + * This trait implements logic to output the messages from readiness checkers + * on admin pages. It may be changed or removed at any time without warning + * and should not be used by external code. + */ +trait ValidationResultDisplayTrait { + + /** + * Gets a message, based on severity, when status checks fail. + * + * @param int $severity + * The severity. Should be one of the SystemManager::REQUIREMENT_* + * constants. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The message. + * + * @see \Drupal\system\SystemManager::REQUIREMENT_ERROR + * @see \Drupal\system\SystemManager::REQUIREMENT_WARNING + */ + private function getFailureMessageForSeverity(int $severity): TranslatableMarkup { + return $severity === SystemManager::REQUIREMENT_WARNING ? + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + $this->t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.') : + $this->t('Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.'); + } + +} diff --git a/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php new file mode 100644 index 000000000000..9da21bb64170 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Lock\LockBackendInterface; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that cron runs frequently enough to perform automatic updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class CronFrequencyValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The error-level interval between cron runs, in seconds. + * + * If cron runs less frequently than this, an error will be raised during + * validation. Defaults to 24 hours. + * + * @var int + */ + private const ERROR_INTERVAL = 86400; + + /** + * The warning-level interval between cron runs, in seconds. + * + * If cron runs less frequently than this, a warning will be raised during + * validation. Defaults to 3 hours. + * + * @var int + */ + private const WARNING_INTERVAL = 10800; + + /** + * The cron frequency, in hours, to suggest in errors or warnings. + * + * @var int + */ + private const SUGGESTED_INTERVAL = self::WARNING_INTERVAL / 3600; + + /** + * CronFrequencyValidator constructor. + * + * @param \Drupal\auto_updates\CronUpdateRunner $cronUpdateRunner + * The cron update runner service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\Core\Lock\LockBackendInterface $lock + * The lock service. + */ + public function __construct( + private readonly CronUpdateRunner $cronUpdateRunner, + private readonly ConfigFactoryInterface $configFactory, + private readonly StateInterface $state, + private readonly TimeInterface $time, + private readonly LockBackendInterface $lock, + ) {} + + /** + * Validates the cron frequency according to the last cron run time. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent $event + * The event object. + */ + public function validateLastCronRun(StatusCheckEvent $event): void { + // We only want to do this check if the stage belongs to Automatic Updates. + if (!$event->stage->getType() === 'auto_updates:unattended') { + return; + } + // If automatic updates are disabled during cron or updates will be run via + // the console command, there's nothing we need to validate. + $method = $this->configFactory->get('auto_updates.settings') + ->get('unattended.method'); + if ($this->cronUpdateRunner->getMode() === CronUpdateRunner::DISABLED || $method !== 'web') { + return; + } + // If cron is running right now, cron is clearly being run recently enough! + if (!$this->lock->lockMayBeAvailable('cron')) { + return; + } + + // Determine when cron last ran. If not known, use the time that Drupal was + // installed, defaulting to the beginning of the Unix epoch. + $cron_last = $this->state->get('system.cron_last', $this->state->get('install_time', 0)); + if ($this->time->getRequestTime() - $cron_last > static::WARNING_INTERVAL) { + $event->addError([ + $this->t('Cron has not run recently. For more information, see the online handbook entry for <a href=":cron-handbook">configuring cron jobs</a> to run at least every @frequency hours.', [ + ':cron-handbook' => 'https://www.drupal.org/cron', + '@frequency' => static::SUGGESTED_INTERVAL, + ]), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'validateLastCronRun', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validator/PhpExtensionsValidator.php b/core/modules/auto_updates/src/Validator/PhpExtensionsValidator.php new file mode 100644 index 000000000000..b7791ea7ec8a --- /dev/null +++ b/core/modules/auto_updates/src/Validator/PhpExtensionsValidator.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Validator\PhpExtensionsValidator as PackageManagerPhpExtensionsValidator; + +/** + * Prevents unattended updates if Xdebug is enabled. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class PhpExtensionsValidator extends PackageManagerPhpExtensionsValidator implements EventSubscriberInterface { + + /** + * {@inheritdoc} + */ + public function validateXdebug(PreOperationStageEvent $event): void { + if ($this->isExtensionLoaded('xdebug') && $event->stage->getType() === 'auto_updates:unattended') { + $event->addError([$this->t("Unattended updates are not allowed while Xdebug is enabled. You cannot receive updates, including security updates, until it is disabled.")]); + } + elseif ($event instanceof StatusCheckEvent) { + parent::validateXdebug($event); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events = parent::getSubscribedEvents(); + $events[PreCreateEvent::class] = 'validateXdebug'; + $events[PreApplyEvent::class] = 'validateXdebug'; + return $events; + } + +} diff --git a/core/modules/auto_updates/src/Validator/RequestedUpdateValidator.php b/core/modules/auto_updates/src/Validator/RequestedUpdateValidator.php new file mode 100644 index 000000000000..04bd7fef0696 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/RequestedUpdateValidator.php @@ -0,0 +1,105 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Composer\Semver\Semver; +use Drupal\auto_updates\UpdateStage; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that requested packages have been updated. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class RequestedUpdateValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a RequestedUpdateValidator 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, + ) {} + + /** + * Validates that requested packages have been updated to the right version. + * + * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\StatusCheckEvent $event + * The pre-apply event. + */ + public function checkRequestedStagedVersion(PreApplyEvent|StatusCheckEvent $event): void { + $stage = $event->stage; + if (!($stage instanceof UpdateStage) || !$stage->stageDirectoryExists()) { + return; + } + $requested_package_versions = $stage->getPackageVersions(); + $active = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $staged = $this->composerInspector->getInstalledPackagesList($event->stage->getStageDirectory()); + $changed_stage_packages = $staged->getPackagesWithDifferentVersionsIn($active)->getArrayCopy(); + + if (empty($changed_stage_packages)) { + $event->addError([$this->t('No updates detected in the staging area.')]); + return; + } + + // Check for all changed the packages if they are updated to the requested + // version. + foreach (['production', 'dev'] as $package_type) { + foreach ($requested_package_versions[$package_type] as $requested_package_name => $requested_version) { + if (array_key_exists($requested_package_name, $changed_stage_packages)) { + $staged_version = $changed_stage_packages[$requested_package_name]->version; + if (!Semver::satisfies($staged_version, $requested_version)) { + $event->addError([ + $this->t( + "The requested update to '@requested_package_name' to version '@requested_version' does not match the actual staged update to '@staged_version'.", + [ + '@requested_package_name' => $requested_package_name, + '@requested_version' => $requested_version, + '@staged_version' => $staged_version, + ] + ), + ]); + } + } + else { + $event->addError([ + $this->t( + "The requested update to '@requested_package_name' to version '@requested_version' was not performed.", + [ + '@requested_package_name' => $requested_package_name, + '@requested_version' => $requested_version, + ] + ), + ]); + } + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[StatusCheckEvent::class][] = ['checkRequestedStagedVersion']; + $events[PreApplyEvent::class][] = ['checkRequestedStagedVersion']; + return $events; + } + +} diff --git a/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php b/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php new file mode 100644 index 000000000000..01ece8e737cb --- /dev/null +++ b/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Validator\StagedDBUpdateValidator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that there are no database updates in a staged update. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class StagedDatabaseUpdateValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a StagedDatabaseUpdateValidator object. + * + * @param \Drupal\package_manager\Validator\StagedDBUpdateValidator $stagedDBUpdateValidator + * The Staged DB Update Validator service. + */ + public function __construct(private readonly StagedDBUpdateValidator $stagedDBUpdateValidator) { + } + + /** + * Checks that the staged update does not have changes to its install files. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function checkUpdateHooks(PreApplyEvent $event): void { + $stage = $event->stage; + if ($stage->getType() !== 'auto_updates:unattended') { + return; + } + + $invalid_extensions = $this->stagedDBUpdateValidator->getExtensionsWithDatabaseUpdates($stage->getStageDirectory()); + if ($invalid_extensions) { + $invalid_extensions = array_map($this->t(...), $invalid_extensions); + $event->addError($invalid_extensions, $this->t('The update cannot proceed because database updates have been detected in the following extensions.')); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'checkUpdateHooks', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php new file mode 100644 index 000000000000..4a30977d262e --- /dev/null +++ b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\UpdateStage; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the staged Drupal projects. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class StagedProjectsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a StagedProjectsValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + */ + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector + ) {} + + /** + * Validates the staged packages. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function validateStagedProjects(PreApplyEvent $event): void { + $stage = $event->stage; + // We only want to do this check if the stage belongs to Automatic Updates. + if (!$stage instanceof UpdateStage) { + return; + } + + $active_list = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $stage_list = $this->composerInspector->getInstalledPackagesList($stage->getStageDirectory()); + + $type_map = [ + 'drupal-module' => $this->t('module'), + 'drupal-custom-module' => $this->t('custom module'), + 'drupal-theme' => $this->t('theme'), + 'drupal-custom-theme' => $this->t('custom theme'), + ]; + $filter = function (InstalledPackage $package) use ($type_map): bool { + return array_key_exists($package->type, $type_map); + }; + $new_packages = $stage_list->getPackagesNotIn($active_list); + $removed_packages = $active_list->getPackagesNotIn($stage_list); + $updated_packages = $active_list->getPackagesWithDifferentVersionsIn($stage_list); + + // Check if any new Drupal projects were installed. + if ($new_packages = array_filter($new_packages->getArrayCopy(), $filter)) { + $new_packages_messages = []; + + foreach ($new_packages as $new_package) { + $new_packages_messages[] = $this->t( + "@type '@name' installed.", + [ + '@type' => $type_map[$new_package->type], + '@name' => $new_package->name, + ] + ); + } + $new_packages_summary = $this->formatPlural( + count($new_packages_messages), + 'The update cannot proceed because the following Drupal project was installed during the update.', + 'The update cannot proceed because the following Drupal projects were installed during the update.' + ); + $event->addError($new_packages_messages, $new_packages_summary); + } + + // Check if any Drupal projects were removed. + if ($removed_packages = array_filter($removed_packages->getArrayCopy(), $filter)) { + $removed_packages_messages = []; + foreach ($removed_packages as $removed_package) { + $removed_packages_messages[] = $this->t( + "@type '@name' removed.", + [ + '@type' => $type_map[$removed_package->type], + '@name' => $removed_package->name, + ] + ); + } + $removed_packages_summary = $this->formatPlural( + count($removed_packages_messages), + 'The update cannot proceed because the following Drupal project was removed during the update.', + 'The update cannot proceed because the following Drupal projects were removed during the update.' + ); + $event->addError($removed_packages_messages, $removed_packages_summary); + } + + // Check if any Drupal projects were neither installed or removed, but had + // their version numbers changed. + if ($updated_packages = array_filter($updated_packages->getArrayCopy(), $filter)) { + + $version_change_messages = []; + foreach ($updated_packages as $name => $updated_package) { + $version_change_messages[] = $this->t( + "@type '@name' from @active_version to @staged_version.", + [ + '@type' => $type_map[$updated_package->type], + '@name' => $updated_package->name, + '@staged_version' => $stage_list[$name]->version, + '@active_version' => $updated_package->version, + ] + ); + } + $version_change_summary = $this->formatPlural( + count($version_change_messages), + 'The update cannot proceed because the following Drupal project was unexpectedly updated. Only Drupal Core updates are currently supported.', + 'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.' + ); + $event->addError($version_change_messages, $version_change_summary); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[PreApplyEvent::class][] = ['validateStagedProjects']; + return $events; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php new file mode 100644 index 000000000000..9b610bdf8a8d --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule that forbids updating from a dev snapshot. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class ForbidDevSnapshot { + + use StringTranslationTrait; + + /** + * Checks if the installed version of Drupal is a dev snapshot. + * + * @param string $installed_version + * The installed version of Drupal. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version): array { + $extra = ExtensionVersion::createFromVersionString($installed_version) + ->getVersionExtra(); + + if ($extra === 'dev') { + return [ + $this->t('Drupal cannot be automatically updated from the installed version, @installed_version, because automatic updates from a dev version to any other version are not supported.', [ + '@installed_version' => $installed_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php new file mode 100644 index 000000000000..36454f1746cd --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Composer\Semver\Comparator; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule that forbids downgrading. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class ForbidDowngrade { + + use StringTranslationTrait; + + /** + * Checks if the target version of Drupal is older than the installed version. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal core, or NULL if not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version): array { + // TRICKY: \Drupal\auto_updates\Validator\VersionPolicyValidator::getTargetVersion() may potentially not be able to determine a version. + $target_version = $target_version ?? ''; + if (Comparator::lessThan($target_version, $installed_version)) { + return [ + $this->t('Update version @target_version is lower than @installed_version, downgrading is not supported.', [ + '@target_version' => $target_version, + '@installed_version' => $installed_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php new file mode 100644 index 000000000000..51f173b11444 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\auto_updates\VersionParsingTrait; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule forbidding minor updates during cron. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class ForbidMinorUpdates { + + use StringTranslationTrait; + use VersionParsingTrait; + + /** + * Checks if the target minor version of Drupal is different than installed. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version): array { + $installed_minor = static::getMajorAndMinorVersion($installed_version); + $target_minor = static::getMajorAndMinorVersion($target_version); + + if ($installed_minor !== $target_minor) { + return [ + $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one minor version to another are not supported during cron.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php b/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php new file mode 100644 index 000000000000..801f334fc7f4 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule that requires updating within the same major version. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class MajorVersionMatch { + + use StringTranslationTrait; + + /** + * Checks that the target version of Drupal is in the same minor as installed. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version): array { + $installed_major = ExtensionVersion::createFromVersionString($installed_version) + ->getMajorVersion(); + $target_major = ExtensionVersion::createFromVersionString($target_version) + ->getMajorVersion(); + + if ($installed_major !== $target_major) { + return [ + $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one major version to another are not supported.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php b/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php new file mode 100644 index 000000000000..6ed7b3f8eef9 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types = 1); + + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule that requiring the installed version to be stable. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class StableReleaseInstalled { + + use StringTranslationTrait; + + /** + * Checks if the installed version of Drupal is a stable release. + * + * @param string $installed_version + * The installed version of Drupal. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version): array { + $extra = ExtensionVersion::createFromVersionString($installed_version) + ->getVersionExtra(); + + if ($extra) { + return [ + $this->t('Drupal cannot be automatically updated during cron from its current version, @installed_version, because it is not a stable version.', [ + '@installed_version' => $installed_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php b/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php new file mode 100644 index 000000000000..b6600ecaa436 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * A policy rule that requires updating from a supported branch. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class SupportedBranchInstalled implements ContainerInjectionInterface { + + use StringTranslationTrait; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + private $configFactory; + + /** + * Constructs a SupportedBranchInstalled object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + */ + public function __construct(ConfigFactoryInterface $config_factory) { + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory') + ); + } + + /** + * Checks if the installed version of Drupal is in a supported branch. + * + * @param string $installed_version + * The installed version of Drupal. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version): array { + $available_updates = update_get_available(TRUE); + + $installed = ExtensionVersion::createFromVersionString($installed_version); + $installed_major = $installed->getMajorVersion(); + $installed_minor = $installed->getMinorVersion(); + $in_supported_major = FALSE; + + $supported_branches = explode(',', $available_updates['drupal']['supported_branches']); + foreach ($supported_branches as $supported_branch) { + $supported_branch = ExtensionVersion::createFromSupportBranch($supported_branch); + + // Check if this supported branch is in the same major version as what's + // installed, since that will influence our messaging. + if ($installed_major === $supported_branch->getMajorVersion()) { + $in_supported_major = TRUE; + + // If the supported branch's major and minor versions are the same as + // the installed ones, this rule is fulfilled. + if ($installed_minor === $supported_branch->getMinorVersion()) { + return []; + } + } + } + + // By this point, we know the installed version of Drupal is not in a + // supported branch, so we'll always show this message. + $messages = [ + $this->t('The currently installed version of Drupal core, @installed_version, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.', [ + '@installed_version' => $installed_version, + ]), + ]; + + // If the installed version of Drupal is in a supported major branch, an + // attended update may be possible, depending on configuration. + $allow_minor_updates = $this->configFactory->get('auto_updates.settings') + ->get('allow_core_minor_updates'); + + if ($in_supported_major && $allow_minor_updates) { + $messages[] = $this->t('Use the <a href=":url">update form</a> to update to a supported version.', [ + ':url' => Url::fromRoute('update.module_update')->toString(), + ]); + } + else { + $messages[] = $this->t('See the <a href=":url">available updates page</a> for available updates.', [ + ':url' => Url::fromRoute('update.status')->toString(), + ]); + } + return $messages; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php new file mode 100644 index 000000000000..3b8bf92cc582 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule requiring the target version to be a security release. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class TargetSecurityRelease { + + use StringTranslationTrait; + + /** + * Checks that the target version of Drupal is a security release. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * @param \Drupal\update\ProjectRelease[] $available_releases + * The available releases of Drupal core. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version, array $available_releases): array { + if (!$available_releases[$target_version]->isSecurityRelease()) { + return [ + $this->t('Drupal cannot be automatically updated during cron from @installed_version to @target_version because @target_version is not a security release.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php new file mode 100644 index 000000000000..f31fd7c1d7ed --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Drupal\auto_updates\VersionParsingTrait; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * A policy rule requiring the target version to be installable. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class TargetVersionInstallable implements ContainerInjectionInterface { + + use StringTranslationTrait; + use VersionParsingTrait; + + /** + * Constructs a TargetVersionInstallable object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct(private readonly ConfigFactoryInterface $configFactory) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory') + ); + } + + /** + * Checks that the target version can be installed. + * + * This means two things must be true: + * - The target minor version of Drupal can be updated to. The update will + * only be allowed if the allow_core_minor_updates flag is TRUE in config. + * - The target version of Drupal is a known installable release. + * + * If the first check fails, there is no need to do the second check because + * the first check implies that the target version isn't installable. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * @param \Drupal\update\ProjectRelease[] $available_releases + * The available releases of Drupal core. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version, array $available_releases): array { + $installed_minor = static::getMajorAndMinorVersion($installed_version); + $target_minor = static::getMajorAndMinorVersion($target_version); + + if ($installed_minor !== $target_minor) { + $minor_updates_allowed = $this->configFactory->get('auto_updates.settings') + ->get('allow_core_minor_updates'); + + if (!$minor_updates_allowed) { + return [ + $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one minor version to another are not supported.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]), + ]; + } + } + // If the target version isn't in the list of installable releases, we + // should flag an error. + if (empty($available_releases) || !array_key_exists($target_version, $available_releases)) { + return [ + $this->t('Cannot update Drupal core to @target_version because it is not in the list of installable releases.', [ + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionNotPreRelease.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionNotPreRelease.php new file mode 100644 index 000000000000..612bc2e491ee --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionNotPreRelease.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Composer\Semver\VersionParser; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule requiring the target version to be a RC or higher. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class TargetVersionNotPreRelease { + + use StringTranslationTrait; + + /** + * Checks that the target version of Drupal is a stable release. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version): array { + if (!in_array(VersionParser::parseStability($target_version), ['stable', 'RC'], TRUE)) { + return [ + $this->t('Drupal cannot be updated to the recommended version, @target_version, because it is not a stable version.', [ + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php new file mode 100644 index 000000000000..f4eac0074ebe --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator\VersionPolicy; + +use Composer\Semver\VersionParser; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * A policy rule requiring the target version to be a stable release. + * + * @internal + * This is an internal part of Automatic Updates' version policy for + * Drupal core. It may be changed or removed at any time without warning. + * External code should not interact with this class. + */ +final class TargetVersionStable { + + use StringTranslationTrait; + + /** + * Checks that the target version of Drupal is a stable release. + * + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages, if any. + */ + public function validate(string $installed_version, ?string $target_version): array { + if (VersionParser::parseStability($target_version) !== 'stable') { + return [ + $this->t('Drupal cannot be automatically updated during cron to the recommended version, @target_version, because it is not a stable version.', [ + '@target_version' => $target_version, + ]), + ]; + } + return []; + } + +} diff --git a/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php b/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php new file mode 100644 index 000000000000..b1f0c977abdc --- /dev/null +++ b/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php @@ -0,0 +1,327 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionNotPreRelease; +use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable; +use Drupal\Component\Utility\NestedArray; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ProjectInfo; +use Drupal\auto_updates\UpdateStage; +use Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade; +use Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates; +use Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch; +use Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled; +use Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot; +use Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled; +use Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease; +use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable; +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StageEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the installed and target versions of Drupal before an update. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class VersionPolicyValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a VersionPolicyValidator object. + * + * @param \Drupal\auto_updates\CronUpdateRunner $cronUpdateRunner + * The cron update runner service. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $classResolver + * The class resolver service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + */ + public function __construct( + private readonly CronUpdateRunner $cronUpdateRunner, + private readonly ClassResolverInterface $classResolver, + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + ) {} + + /** + * Validates a target version of Drupal core. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage which will perform the update. + * @param string|null $target_version + * The target version of Drupal core, or NULL if it is not known. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error messages returned from the first policy rule which rejected + * the given target version. + * + * @see \Drupal\auto_updates\Validator\VersionPolicy\RuleBase::validate() + */ + public function validateVersion(UpdateStage $stage, ?string $target_version): array { + // Check that the installed version of Drupal isn't a dev snapshot. + $rules = [ + ForbidDevSnapshot::class, + ]; + + // If the target version is known, it must conform to a few basic rules. + if ($target_version) { + // The target version must be newer than the installed version... + $rules[] = ForbidDowngrade::class; + // ...and in the same major version as the installed version... + $rules[] = MajorVersionMatch::class; + // ...and it must be a known, secure, installable release... + $rules[] = TargetVersionInstallable::class; + // @todo Remove the need to check for the stage instance in + // https://drupal.org/i/3398782. + if ($stage->getType() !== 'auto_updates:unattended') { + // ...and must be either a release candidate, or stable. + $rules[] = TargetVersionNotPreRelease::class; + } + } + + // If this is a cron update, we may need to do additional checks. + if ($stage->getType() === 'auto_updates:unattended') { + $mode = $this->cronUpdateRunner->getMode(); + + // @todo Remove the need to check if cron updates are disabled in + // https://drupal.org/i/3398782. + if ($mode !== CronUpdateRunner::DISABLED) { + // If cron updates are enabled, the installed version must be stable; + // no alphas, betas, or RCs. + $rules[] = StableReleaseInstalled::class; + // It must also be in a supported branch. + $rules[] = SupportedBranchInstalled::class; + + // If the target version is known, more rules apply. + if ($target_version) { + // The target version must be stable too... + $rules[] = TargetVersionStable::class; + // ...and it must be in the same minor as the installed version. + $rules[] = ForbidMinorUpdates::class; + + // If only security updates are allowed during cron, the target + // version must be a security release. + if ($mode === CronUpdateRunner::SECURITY) { + $rules[] = TargetSecurityRelease::class; + } + } + } + } + + $installed_version = $this->getInstalledVersion(); + $available_releases = $this->getAvailableReleases($stage); + + // Let all the rules flag whatever messages they need to. + $messages = []; + foreach ($rules as $rule) { + $messages[$rule] = $this->classResolver->getInstanceFromDefinition($rule) + ->validate($installed_version, $target_version, $available_releases); + } + // Remove any messages that are superseded by other, more specific ones. + $filtered_rule_messages = array_filter($messages, fn ($rule) => !self::isRuleSuperseded($rule, $messages), ARRAY_FILTER_USE_KEY); + // Collapse all the rules' messages into a single array. + return NestedArray::mergeDeepArray($filtered_rule_messages); + } + + /** + * Check if a given rule's messages are superseded by a more specific rule. + * + * @param string $rule + * The rule to check. + * @param array[] $rule_messages + * The messages that were returned by the various rules, keyed by the name + * of the rule that returned them. + * + * @return bool + * TRUE if the given rule is superseded by another rule, FALSE otherwise. + */ + private static function isRuleSuperseded(string $rule, array $rule_messages): bool { + // Some rules' messages are more specific than other rules' messages. For + // example, if the message "… automatic updates from one major version to + // another are not supported" is returned, then the message "… not in the + // list of installable releases" is not needed because the new major version + // will not be in the list of installable releases. The keys of this array + // are the rules which supersede messages from the values, which are the + // less specific rules. + $more_specific_rule_sets = [ + ForbidDowngrade::class => [TargetVersionInstallable::class, MajorVersionMatch::class], + ForbidDevSnapshot::class => [StableReleaseInstalled::class], + MajorVersionMatch::class => [TargetVersionInstallable::class], + ForbidMinorUpdates::class => [TargetVersionInstallable::class], + TargetVersionStable::class => [TargetVersionNotPreRelease::class], + ]; + foreach ($more_specific_rule_sets as $more_specific_rule => $less_specific_rules) { + // If the more specific rule flagged any messages, the given rule is + // superseded. + if (!empty($rule_messages[$more_specific_rule]) && in_array($rule, $less_specific_rules, TRUE)) { + return TRUE; + } + } + return FALSE; + } + + /** + * Checks that the target version of Drupal is valid. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function checkVersion(StageEvent $event): void { + $stage = $event->stage; + + // Only do these checks for automatic updates. + if (!$stage instanceof UpdateStage) { + return; + } + $target_version = $this->getTargetVersion($event); + + $messages = $this->validateVersion($stage, $target_version); + if ($messages) { + $installed_version = $this->getInstalledVersion(); + + if ($target_version) { + $summary = $this->t('Updating from Drupal @installed_version to @target_version is not allowed.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]); + } + else { + $summary = $this->t('Updating from Drupal @installed_version is not allowed.', [ + '@installed_version' => $installed_version, + ]); + } + $event->addError($messages, $summary); + } + } + + /** + * Returns the target version of Drupal core. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + * + * @return string|null + * The target version of Drupal core, or NULL if it could not be determined + * during a status check. + * + * @throws \LogicException + * Thrown if the target version cannot be determined due to unexpected + * conditions. This can happen if, during a stage life cycle event (i.e., + * NOT a status check), the event or update stage does not have a list of + * desired package versions, or the list of package versions does not + * include any Drupal core packages. + */ + private function getTargetVersion(StageEvent $event): ?string { + $stage = $event->stage; + + // If we're not doing a status check, we expect the stage to have been + // created, and the requested package versions recorded. + if (!$event instanceof StatusCheckEvent) { + $package_versions = $stage->getPackageVersions()['production']; + } + + $unknown_target = new \LogicException('The target version of Drupal core could not be determined.'); + + if (isset($package_versions)) { + $core_package_name = $this->getCorePackageName(); + + if ($core_package_name && array_key_exists($core_package_name, $package_versions)) { + return $package_versions[$core_package_name]; + } + else { + throw $unknown_target; + } + } + elseif ($event instanceof StatusCheckEvent) { + if ($stage->getType() === 'auto_updates:unattended') { + $target_release = $stage->getTargetRelease(); + if ($target_release) { + return $target_release->getVersion(); + } + } + return NULL; + } + // If we got here, something has gone very wrong. + throw $unknown_target; + } + + /** + * Returns the available releases of Drupal core for a given update stage. + * + * @param \Drupal\auto_updates\UpdateStage $stage + * The update stage which will perform the update. + * + * @return \Drupal\update\ProjectRelease[] + * The available releases of Drupal core, keyed by version number and in + * descending order (i.e., newest first). Will be in ascending order (i.e., + * oldest first) if $stage is the cron update runner. + * + * @see \Drupal\package_manager\ProjectInfo::getInstallableReleases() + */ + private function getAvailableReleases(UpdateStage $stage): array { + $project_info = new ProjectInfo('drupal'); + $available_releases = $project_info->getInstallableReleases() ?? []; + + if ($stage->getType() === 'auto_updates:unattended') { + $available_releases = array_reverse($available_releases); + } + return $available_releases; + } + + /** + * Returns the currently installed version of Drupal core. + * + * @return string|null + * The currently installed version of Drupal core, or NULL if it could not + * be determined. + */ + private function getInstalledVersion(): ?string { + return (new ProjectInfo('drupal'))->getInstalledVersion(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'checkVersion', + StatusCheckEvent::class => 'checkVersion', + ]; + } + + /** + * Returns the name of the first known installed core package. + * + * This does NOT include dev packages like `drupal/core-dev` and + * `drupal/core-dev-pinned`. + * + * @return string|bool + * The name of the first known installed core package (most likely + * `drupal/core` or `drupal/core-recommended`), or FALSE if none is found. + */ + private function getCorePackageName(): string|bool { + $project_root = $this->pathLocator->getProjectRoot(); + + $core_packages = $this->composerInspector->getInstalledPackagesList($project_root) + ->getCorePackages(FALSE) + ->getArrayCopy(); + + return key($core_packages) ?? FALSE; + } + +} diff --git a/core/modules/auto_updates/src/Validator/WindowsValidator.php b/core/modules/auto_updates/src/Validator/WindowsValidator.php new file mode 100644 index 000000000000..63c2c384be7a --- /dev/null +++ b/core/modules/auto_updates/src/Validator/WindowsValidator.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\Core\Config\ConfigFactoryInterface; +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 Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Disallows unattended background updates on Windows systems. + */ +final class WindowsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The value of the PHP_OS constant. + * + * @var string + */ + private static $os = PHP_OS; + + /** + * Constructs a WindowsValidator object. + * + * @param \Drupal\auto_updates\CronUpdateRunner $cronRunner + * The cron update runner service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + private readonly CronUpdateRunner $cronRunner, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'validate', + PreCreateEvent::class => 'validate', + ]; + } + + /** + * Disallows unattended updates if running on Windows. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event being handled. + */ + public function validate(PreOperationStageEvent $event): void { + // If we're not on Windows, there's nothing for us to validate. + if (!str_starts_with(strtoupper(static::$os), 'WIN')) { + return; + } + + $method = $this->configFactory->get('auto_updates.settings') + ->get('unattended.method'); + + $stage = $event->stage; + if ($stage->getType() === 'auto_updates:unattended' && $this->cronRunner->getMode() !== CronUpdateRunner::DISABLED && $method === 'web') { + $message = $this->t('Unattended updates are not supported on Windows.'); + + $form_url = Url::fromRoute('update.report_update'); + if ($form_url->access()) { + $message = $this->t('@message Use <a href=":form-url">the update form</a> to update Drupal core.', [ + '@message' => $message, + ':form-url' => $form_url->toString(), + ]); + } + $event->addError([$message]); + } + } + +} diff --git a/core/modules/auto_updates/src/VersionParsingTrait.php b/core/modules/auto_updates/src/VersionParsingTrait.php new file mode 100644 index 000000000000..5534ea75c16f --- /dev/null +++ b/core/modules/auto_updates/src/VersionParsingTrait.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates; + +use Drupal\Core\Extension\ExtensionVersion; + +/** + * Common function for parsing version traits. + * + * @internal + * This trait may be removed in patch or minor versions. + */ +trait VersionParsingTrait { + + /** + * Gets the patch number from a version string. + * + * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in + * https://www.drupal.org/i/3261744. + * + * @param string $version_string + * The version string. + * + * @return string|null + * The patch number if available, otherwise NULL. + */ + private static function getPatchVersion(string $version_string): ?string { + $version_extra = ExtensionVersion::createFromVersionString($version_string) + ->getVersionExtra(); + if ($version_extra) { + $version_string = str_replace("-$version_extra", '', $version_string); + } + $version_parts = explode('.', $version_string); + return count($version_parts) === 3 ? $version_parts[2] : NULL; + } + + /** + * Returns the semantic major.minor numbers of a version string. + * + * @param string $version + * The version string. + * + * @return string + * The major.minor numbers of the version string. For example, if $version + * is 8.9.1, '8.9' will be returned. + */ + private static function getMajorAndMinorVersion(string $version): string { + $version = ExtensionVersion::createFromVersionString($version); + return $version->getMajorVersion() . '.' . $version->getMinorVersion(); + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml new file mode 100644 index 000000000000..2ca437e523d1 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml @@ -0,0 +1,7 @@ +name: 'Automatic Updates Test module' +type: module +description: 'Module for testing Automatic Updates.' +package: Testing +dependencies: + - drupal:auto_updates + - auto_updates:package_manager_test_validation diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install new file mode 100644 index 000000000000..e30a537ecefd --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Contains install and update hooks. + */ + +if (\Drupal::state()->get('auto_updates_test.new_update')) { + + /** + * Dynamic auto_updates_update_1191934. + */ + function auto_updates_update_1191934(&$sandbox) { + + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module new file mode 100644 index 000000000000..628581ca190d --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module @@ -0,0 +1,29 @@ +<?php + +/** + * @file + * Contains hook implementations for testing Automatic Updates. + */ + +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Implements hook_mail_alter(). + */ +function auto_updates_test_mail_alter(array &$message): void { + if (str_starts_with($message['id'], 'auto_updates_')) { + $line_langcodes = []; + + // Get the langcode of every translated line in the message, including the + // subject line. + $lines = array_merge($message['body'], [ + $message['subject'], + ]); + foreach ($lines as $line) { + if ($line instanceof TranslatableMarkup) { + $line_langcodes[] = $line->getOption('langcode'); + } + } + $message['line_langcodes'] = array_unique($line_langcodes); + } +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml new file mode 100644 index 000000000000..3e46bff0d0df --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml @@ -0,0 +1,15 @@ +services: + auto_updates_test.checker: + class: Drupal\auto_updates_test\EventSubscriber\TestSubscriber1 + tags: + - { name: event_subscriber } + arguments: ['@state'] + auto_updates_test.request.time: + class: Drupal\auto_updates_test\EventSubscriber\RequestTimeRecorder + tags: + - { name: event_subscriber } + arguments: ['@state', '@datetime.time'] + auto_updates_test.time: + class: Drupal\auto_updates_test\Datetime\TestTime + decorates: datetime.time + arguments: ['@auto_updates_test.time.inner','@request_stack'] diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php new file mode 100644 index 000000000000..ea65d14db57e --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates_test\Datetime; + +use Drupal\Component\Datetime\Time; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Test service for altering the request time. + */ +class TestTime extends Time { + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\Time + */ + protected $decoratorTime; + + /** + * Constructs a TestTime object. + * + * @param \Drupal\Component\Datetime\Time $time + * The time service. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The RequestStack object. + */ + public function __construct(Time $time, RequestStack $request_stack) { + $this->decoratorTime = $time; + parent::__construct($request_stack); + } + + /** + * {@inheritdoc} + */ + public function getRequestTime(): int { + if ($faked_date = \Drupal::state()->get('auto_updates_test.fake_date_time')) { + return \DateTime::createFromFormat('U', $faked_date)->getTimestamp(); + } + return $this->decoratorTime->getRequestTime(); + } + + /** + * Sets a fake time from an offset that will be used in the test. + * + * @param string $offset + * A date/time offset string as used by \DateTime::modify. + */ + public static function setFakeTimeByOffset(string $offset): void { + $fake_time = (new \DateTime())->modify($offset)->format('U'); + \Drupal::state()->set('auto_updates_test.fake_date_time', $fake_time); + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php new file mode 100644 index 000000000000..8073a81d4207 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates_test\EventSubscriber; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StageEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Records the request time during various events. + */ +class RequestTimeRecorder implements EventSubscriberInterface { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * Constructs a new instance. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + */ + public function __construct(StateInterface $state, TimeInterface $time) { + $this->state = $state; + $this->time = $time; + } + + /** + * Records the request time. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function updateState(StageEvent $event) { + $key = get_class($event) . ' time'; + $this->state->set($key, $this->time->getRequestMicroTime()); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreApplyEvent::class => 'updateState', + PostApplyEvent::class => 'updateState', + ]; + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/TestSubscriber1.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/TestSubscriber1.php new file mode 100644 index 000000000000..bbdb16edad98 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/TestSubscriber1.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates_test\EventSubscriber; + +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; + +/** + * A test status checker. + */ +class TestSubscriber1 extends TestSubscriber { +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.info.yml new file mode 100644 index 000000000000..7f8c149eee06 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.info.yml @@ -0,0 +1,6 @@ +name: 'Automatic Updates Test API' +description: 'Provides API endpoints for doing stage operations in functional tests.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager_test_api diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.routing.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.routing.yml new file mode 100644 index 000000000000..c22ce337bc15 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_api/auto_updates_test_api.routing.yml @@ -0,0 +1,23 @@ +auto_updates_test_api.update: + path: '/automatic-updates-test-api' + defaults: + _controller: 'Drupal\auto_updates_test_api\ApiController::run' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE +auto_updates_test_api.finish: + path: '/automatic-updates-test-api/finish/{id}' + defaults: + _controller: 'Drupal\auto_updates_test_api\ApiController::finish' + requirements: + _access: 'TRUE' +auto_updates_test_api.reset_cron: + path: '/automatic-updates-test-api/reset-cron' + defaults: + _controller: 'Drupal\auto_updates_test_api\ApiController::resetCron' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE + no_cache: TRUE diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_api/src/ApiController.php b/core/modules/auto_updates/tests/modules/auto_updates_test_api/src/ApiController.php new file mode 100644 index 000000000000..15fbcec774db --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_api/src/ApiController.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates_test_api; + +use Drupal\auto_updates\UpdateStage; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager_test_api\ApiController as PackageManagerApiController; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class ApiController extends PackageManagerApiController { + + /** + * {@inheritdoc} + */ + protected $finishedRoute = 'auto_updates_test_api.finish'; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get(UpdateStage::class), + $container->get(PathLocator::class), + ); + } + + /** + * {@inheritdoc} + */ + protected function createAndApplyStage(Request $request): string { + $id = $this->stage->begin($request->get('projects', [])); + $this->stage->stage(); + $this->stage->apply(); + return $id; + } + + /** + * Deletes last cron run time, so Automated Cron will run during this request. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function resetCron(): Response { + \Drupal::state()->delete('system.cron_last'); + return new Response('cron reset'); + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.info.yml new file mode 100644 index 000000000000..26b86b7a5c3b --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.info.yml @@ -0,0 +1,6 @@ +name: 'Automatic Updates Test Status Checker Provider' +type: module +description: 'Test module to provide an additional status checker.' +package: Testing +dependencies: + - drupal:auto_updates_test diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.services.yml new file mode 100644 index 000000000000..2d413000fa67 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/auto_updates_test_status_checker.services.yml @@ -0,0 +1,6 @@ +services: + auto_updates_test_status_checker.checker: + class: Drupal\auto_updates_test_status_checker\EventSubscriber\TestSubscriber2 + tags: + - { name: event_subscriber } + arguments: ['@state'] diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/src/EventSubscriber/TestSubscriber2.php b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/src/EventSubscriber/TestSubscriber2.php new file mode 100644 index 000000000000..2efcdd939bae --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_status_checker/src/EventSubscriber/TestSubscriber2.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\auto_updates_test_status_checker\EventSubscriber; + +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StatusCheckEvent; + +/** + * A test status checker. + */ +class TestSubscriber2 extends TestSubscriber1 { + + protected const STATE_KEY = 'auto_updates_test_status_checker.checker_results'; + + public static function getSubscribedEvents(): array { + $events[StatusCheckEvent::class][] = ['handleEvent', 4]; + $events[PreCreateEvent::class][] = ['handleEvent', 4]; + + return $events; + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php new file mode 100644 index 000000000000..637f1f7e93d2 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php @@ -0,0 +1,481 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Build; + +use Behat\Mink\Element\DocumentElement; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\UpdateStage; +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\Tests\WebAssert; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Tests an end-to-end update of Drupal core. + * + * @group auto_updates + * @internal + */ +class CoreUpdateTest extends UpdateTestBase { + + /** + * WebAssert object. + * + * @var \Drupal\Tests\WebAssert + */ + protected $webAssert; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->webAssert = new WebAssert($this->getMink()->getSession()); + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL): void { + parent::copyCodebase($iterator, $working_dir); + + // Ensure that we will install Drupal 9.8.0 (a fake version that should + // never exist in real life) initially. + $this->setUpstreamCoreVersion('9.8.0'); + } + + /** + * {@inheritdoc} + */ + public function getCodebaseFinder() { + // Don't copy .git directories and such, since that just slows things down. + // We can use ::setUpstreamCoreVersion() to explicitly set the versions of + // core packages required by the test site. + return parent::getCodebaseFinder()->ignoreVCS(TRUE); + } + + /** + * {@inheritdoc} + */ + protected function createTestProject(string $template): void { + parent::createTestProject($template); + + // Prepare an "upstream" version of core, 9.8.1, to which we will update. + // This version, along with 9.8.0 (which was installed initially), is + // referenced in our fake release metadata (see + // fixtures/release-history/drupal.0.0.xml). + $this->setUpstreamCoreVersion('9.8.1'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + + // Ensure that Drupal thinks we are running 9.8.0, then refresh information + // about available updates and ensure that an update to 9.8.1 is available. + $this->assertCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->visit('/admin/modules/update'); + $this->getMink()->assertSession()->pageTextContains('9.8.1'); + + $this->assertStatusReportChecksSuccessful(); + + // Ensure that Drupal has write-protected the site directory. + $this->assertDirectoryIsNotWritable($this->getWebRoot() . '/sites/default'); + } + + /** + * Tests an end-to-end core update via the API. + */ + public function testApi(): void { + $this->createTestProject('RecommendedProject'); + $query = http_build_query([ + 'projects' => [ + 'drupal' => '9.8.1', + ], + ]); + // Ensure that the update is prevented if the web root and/or vendor + // directories are not writable. + $this->assertReadOnlyFileSystemError("/automatic-updates-test-api?$query"); + + $mink = $this->getMink(); + $session = $mink->getSession(); + $session->reload(); + $update_status_code = $session->getStatusCode(); + $file_contents = $session->getPage()->getContent(); + $this->assertExpectedStageEventsFired( + UpdateStage::class, + [ + // ::assertReadOnlyFileSystemError attempts to start an update + // multiple times so 'PreCreateEvent' will be fired multiple times. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + PreCreateEvent::class, + PreCreateEvent::class, + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + ], + message: 'Error response: ' . $file_contents + ); + // Even though the response is what we expect, assert the status code as + // well, to be extra-certain that there was no kind of server-side error. + $this->assertSame(200, $update_status_code); + + $this->assertStringContainsString( + "const VERSION = '9.8.1';", + file_get_contents($this->getWebRoot() . '/core/lib/Drupal.php') + ); + $this->assertUpdateSuccessful('9.8.1'); + + $this->assertRequestedChangesWereLogged([ + 'Update drupal/core-dev from 9.8.0 to 9.8.1', + 'Update drupal/core-recommended from 9.8.0 to 9.8.1', + ]); + $this->assertAppliedChangesWereLogged([ + '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', + ]); + } + + /** + * Tests updating during cron using the Automated Cron module. + */ + public function testAutomatedCron(): void { + $this->createTestProject('RecommendedProject'); + $this->installModules(['automated_cron']); + + // Reset the record of the last cron run. + $this->visit('/automatic-updates-test-api/reset-cron'); + $this->getMink()->assertSession()->pageTextContains('cron reset'); + // Make another request so that Automated Cron will be triggered at the end + // of the request. + $this->visit('/'); + $this->assertExpectedStageEventsFired(ConsoleUpdateStage::class, wait: 360); + $this->assertCronUpdateSuccessful(); + } + + /** + * Tests an end-to-end core update via the UI. + */ + public function testUi(): void { + $this->createTestProject('RecommendedProject'); + + $mink = $this->getMink(); + $session = $mink->getSession(); + $page = $session->getPage(); + $assert_session = $mink->assertSession(); + $this->coreUpdateTillUpdateReady($page); + $page->pressButton('Continue'); + $this->waitForBatchJob(); + $assert_session->addressEquals('/admin/reports/updates'); + $assert_session->pageTextContains('Update complete!'); + $assert_session->pageTextContains('Up to date'); + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + $this->assertExpectedStageEventsFired(UpdateStage::class); + $this->assertUpdateSuccessful('9.8.1'); + $this->assertRequestedChangesWereLogged([ + 'Update drupal/core-dev from 9.8.0 to 9.8.1', + 'Update drupal/core-recommended from 9.8.0 to 9.8.1', + ]); + $this->assertAppliedChangesWereLogged([ + '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', + ]); + } + + /** + * Tests an end-to-end core update via cron. + * + * @param string $template + * The template project from which to build the test site. + * + * @dataProvider providerTemplate + */ + public function testCron(string $template): void { + $this->createTestProject($template); + + $this->visit('/admin/reports/status'); + $session = $this->getMink()->getSession(); + + $session->getPage()->clickLink('Run cron'); + $this->assertSame(200, $session->getStatusCode()); + $this->assertExpectedStageEventsFired(ConsoleUpdateStage::class, wait: 360); + $this->assertCronUpdateSuccessful(); + } + + /** + * Tests stage is destroyed if not available and site is on insecure version. + */ + public function testStageDestroyedIfNotAvailable(): void { + $this->createTestProject('RecommendedProject'); + $mink = $this->getMink(); + $session = $mink->getSession(); + $page = $session->getPage(); + $assert_session = $mink->assertSession(); + $this->coreUpdateTillUpdateReady($page); + $this->visit('/admin/reports/status'); + $assert_session->pageTextContains('Your site is ready for automatic updates.'); + $page->clickLink('Run cron'); + // The stage will first destroy the stage made above before going through + // stage lifecycle events for the cron update. + $expected_events = [ + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + ]; + $this->assertExpectedStageEventsFired(ConsoleUpdateStage::class, $expected_events, 360); + $this->assertCronUpdateSuccessful(); + } + + /** + * Asserts that the update is prevented if the filesystem isn't writable. + * + * @param string $error_url + * A URL where we can see the error message which is raised when parts of + * the file system are not writable. This URL will be visited twice: once + * for the web root, and once for the vendor directory. + */ + private function assertReadOnlyFileSystemError(string $error_url): void { + $directories = [ + 'Drupal' => rtrim($this->getWebRoot(), './'), + ]; + + // The location of the vendor directory depends on which project template + // was used to build the test site, so just ask Composer where it is. + $directories['vendor'] = $this->runComposer('composer config --absolute vendor-dir', 'project'); + + $assert_session = $this->getMink()->assertSession(); + foreach ($directories as $type => $path) { + chmod($path, 0555); + $this->assertDirectoryIsNotWritable($path); + $this->visit($error_url); + $assert_session->pageTextContains("The $type directory \"$path\" is not writable."); + chmod($path, 0755); + $this->assertDirectoryIsWritable($path); + } + } + + /** + * Asserts that a specific version of Drupal core is running. + * + * Assumes that a user with permission to view the status report is logged in. + * + * @param string $expected_version + * The version of core that should be running. + */ + protected function assertCoreVersion(string $expected_version): void { + $this->visit('/admin/reports/status'); + $item = $this->getMink() + ->assertSession() + ->elementExists('css', 'h3:contains("Drupal Version")') + ->getParent() + ->getText(); + $this->assertStringContainsString($expected_version, $item); + } + + /** + * Asserts that Drupal core was updated successfully. + * + * Assumes that a user with appropriate permissions is logged in. + * + * @param string $expected_version + * The expected active version of Drupal core. + */ + private function assertUpdateSuccessful(string $expected_version): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $assert_session = $mink->assertSession(); + + // There should be log messages, but no errors or warnings should have been + // logged by Automatic Updates. + // The use of the database log here implies one can only retrieve log + // entries through the dblog UI. This seems non-ideal but it is the choice + // that requires least custom configuration or custom code. Using the + // `syslog` or `syslog_test` module or the `@RestResource=dblog` plugin for + // the `rest` module require more additional code than the inflexible log + // querying below. + $this->visit('/admin/reports/dblog'); + $type_select_field = $assert_session->fieldExists('Type'); + + // Automatic Updates may not have logged any entries but if it did there + // should not have been any errors or warnings. + if ($type_select_field->find('named', ['option', 'auto_updates'])) { + $assert_session->pageTextNotContains('No log messages available.'); + $page->selectFieldOption('Type', 'auto_updates'); + $page->selectFieldOption('Severity', 'Emergency', TRUE); + $page->selectFieldOption('Severity', 'Alert', TRUE); + $page->selectFieldOption('Severity', 'Critical', TRUE); + $page->selectFieldOption('Severity', 'Warning', TRUE); + $page->pressButton('Filter'); + $assert_session->pageTextContains('No log messages available.'); + } + + // \Drupal\auto_updates\Routing\RouteSubscriber::alterRoutes() sets + // `_auto_updates_status_messages: skip` on the route for the path + // `/admin/modules/reports/status`, but not on the `/admin/reports` path. So + // to test AdminStatusCheckMessages::displayAdminPageMessages(), another + // page must be visited. `/admin/reports` was chosen, but it could be + // another too. + $this->visit('/admin/reports'); + $assert_session->statusCodeEquals(200); + // @see \Drupal\auto_updates\Validation\AdminStatusCheckMessages::displayAdminPageMessages() + $this->webAssert->statusMessageNotExists('error'); + $this->webAssert->statusMessageNotExists('warning'); + + $web_root = $this->getWebRoot(); + $placeholder = file_get_contents("$web_root/core/README.txt"); + $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder); + + foreach (['default.settings.php', 'default.services.yml'] as $file) { + $file = $web_root . '/sites/default/' . $file; + $this->assertFileIsReadable($file); + // The `default.settings.php` and `default.services.yml` files are + // explicitly excluded from Package Manager operations, since they are not + // relevant to existing sites. Therefore, ensure that the changes we made + // to the original (scaffold) versions of the files are not present in + // the updated site. + // @see \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder() + // @see \Drupal\Tests\package_manager\Build\TemplateProjectTestBase::setUpstreamCoreVersion() + $this->assertStringNotContainsString("# This is part of Drupal $expected_version.", file_get_contents($file)); + } + $this->assertDirectoryIsNotWritable("$web_root/sites/default"); + + $info = $this->runComposer('composer info --self --format json', 'project', TRUE); + + // The production dependencies should have been updated. + $this->assertSame($expected_version, $info['requires']['drupal/core-recommended']); + $this->assertSame($expected_version, $info['requires']['drupal/core-composer-scaffold']); + $this->assertSame($expected_version, $info['requires']['drupal/core-project-message']); + // The core-vendor-hardening plugin is only used by the legacy project + // template. + if ($info['name'] === 'drupal/legacy-project') { + $this->assertSame($expected_version, $info['requires']['drupal/core-vendor-hardening']); + } + // The production dependencies should not be listed as dev dependencies. + $this->assertArrayNotHasKey('drupal/core-recommended', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-project-message', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $info['devRequires']); + + // The drupal/core-dev metapackage should not be a production dependency... + $this->assertArrayNotHasKey('drupal/core-dev', $info['requires']); + // ...but it should have been updated in the dev dependencies. + $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']); + // The update form should not have any available updates. + $this->visit('/admin/modules/update'); + $assert_session = $this->getMink()->assertSession(); + $assert_session->pageTextContains('No update available'); + $assert_session->pageTextNotContains('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.'); + + // The status page should report that we're running the expected version and + // the README and default site configuration files should contain the + // placeholder text written by ::setUpstreamCoreVersion(), even though + // `sites/default` is write-protected. + // @see ::createTestProject() + // @see ::setUpstreamCoreVersion() + $this->assertCoreVersion($expected_version); + } + + /** + * Performs core update till update ready form. + * + * @param \Behat\Mink\Element\DocumentElement $page + * The page element. + */ + private function coreUpdateTillUpdateReady(DocumentElement $page): void { + $session = $this->getMink()->getSession(); + $this->visit('/admin/modules'); + $assert_session = $this->getMink()->assertSession($session); + $assert_session->pageTextContains('There is a security update available for your version of Drupal.'); + $page->clickLink('Update'); + + // Ensure that the update is prevented if the web root and/or vendor + // directories are not writable. + $this->assertReadOnlyFileSystemError(parse_url($session->getCurrentUrl(), PHP_URL_PATH)); + $session->reload(); + + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + // Ensure test failures provide helpful debug output when failing readiness + // checks prevent updates. + // @see \Drupal\Tests\WebAssert::buildStatusMessageSelector() + if ($error_message = $session->getPage()->find('xpath', '//div[@data-drupal-messages]//div[@aria-label="Error message"]')) { + /** @var \Behat\Mink\Element\NodeElement $error_message */ + $this->assertSame('', $error_message->getText()); + } + $page->pressButton('Update to 9.8.1'); + $this->waitForBatchJob(); + $assert_session->pageTextContains('Ready to update'); + } + + /** + * Assert a cron update ran successfully. + */ + private function assertCronUpdateSuccessful(): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $this->visit('/admin/reports/dblog'); + + // Ensure that the update occurred. + $page->selectFieldOption('Severity', 'Info'); + $page->pressButton('Filter'); + // There should be a log entry about the successful update. + $mink->assertSession() + ->elementAttributeContains('named', ['link', 'Drupal core has been updated from 9.8.0 to 9.8.1'], 'href', '/admin/reports/dblog/event/'); + $this->assertUpdateSuccessful('9.8.1'); + } + + /** + * Tests updating via the console directly. + */ + public function testConsoleUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $command = [ + (new PhpExecutableFinder())->find(), + $this->getWebRoot() . '/core/scripts/auto-update', + '--verbose', + ]; + + $process = (new Process($command)) + ->setWorkingDirectory($this->getWorkspaceDirectory() . '/project') + // Give the update process as much time as it needs to run. + ->setTimeout(NULL); + + $output = $process->mustRun()->getOutput(); + $this->assertStringContainsString('Updating Drupal core to 9.8.1. This may take a while.', $output); + $this->assertStringContainsString('Running post-apply tasks and final clean-up...', $output); + $this->assertStringContainsString('Drupal core was successfully updated to 9.8.1!', $output); + $this->assertStringContainsString('Deleting unused stage directories...', $output); + $this->assertUpdateSuccessful('9.8.1'); + $this->assertExpectedStageEventsFired(ConsoleUpdateStage::class); + + $pattern = '/^Unused stage directory deleted: (.+)$/m'; + $matches = []; + preg_match($pattern, $output, $matches); + $this->assertCount(2, $matches); + $this->assertDirectoryDoesNotExist($matches[1]); + + // Rerunning the command should exit with a message that no newer version + // is available. + $output = $process->mustRun()->getOutput(); + $this->assertStringContainsString("There is no Drupal core update available.", $output); + // Any defunct stage directories should still be cleaned up (even though + // there aren't any left). + $this->assertStringContainsString('Deleting unused stage directories...', $output); + $this->assertDoesNotMatchRegularExpression($pattern, $output); + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php new file mode 100644 index 000000000000..6a1e60a76b75 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Build; + +use Drupal\Component\Utility\Html; +use Drupal\Tests\package_manager\Build\TemplateProjectTestBase; + +/** + * Base class for tests that perform in-place updates. + * + * @internal + */ +abstract class UpdateTestBase extends TemplateProjectTestBase { + + /** + * {@inheritdoc} + */ + protected function createTestProject(string $template): void { + parent::createTestProject($template); + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $code = <<<END +\$config['auto_updates.settings']['unattended']['level'] = 'security'; +END; + $this->writeSettings($code); + // Install Automatic Updates, and other modules needed for testing. + $this->installModules([ + 'auto_updates', + 'auto_updates_test_api', + ]); + + // Uninstall Automated Cron because this will run cron updates on most + // requests, making it difficult to test other forms of updating. + // Also uninstall Big Pipe, since it may cause page elements to be rendered + // in the background and replaced with JavaScript, which isn't supported in + // build tests. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::testAutomatedCron + $page = $this->getMink()->getSession()->getPage(); + $this->visit('/admin/modules/uninstall'); + $page->checkField("uninstall[automated_cron]"); + $page->checkField('uninstall[big_pipe]'); + $page->pressButton('Uninstall'); + $page->pressButton('Uninstall'); + } + + /** + * Checks for available updates. + * + * Assumes that a user with the appropriate access is logged in. + */ + protected function checkForUpdates(): void { + $this->visit('/admin/reports/updates'); + $this->getMink()->getSession()->getPage()->clickLink('Check manually'); + $this->waitForBatchJob(); + } + + /** + * Waits for an active batch job to finish. + */ + protected function waitForBatchJob(): void { + $refresh = $this->getMink() + ->getSession() + ->getPage() + ->find('css', 'meta[http-equiv="Refresh"], meta[http-equiv="refresh"]'); + + if ($refresh) { + // Parse the content attribute of the meta tag for the format: + // "[delay]: URL=[page_to_redirect_to]". + if (preg_match('/\d+;\s*URL=\'?(?<url>[^\']*)/i', $refresh->getAttribute('content'), $match)) { + $url = Html::decodeEntities($match['url']); + $this->visit($url); + $this->waitForBatchJob(); + } + } + } + + /** + * Asserts the status report does not have any readiness errors or warnings. + */ + protected function assertStatusReportChecksSuccessful(): void { + $this->visit('/admin/reports/status'); + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $page->clickLink('Rerun readiness checks'); + + $readiness_check_summaries = $page->findAll('css', '*:contains("Update readiness checks")'); + // There should always either be the summary section indicating the site is + // ready for automatic updates or the error or warning sections. + $this->assertNotEmpty($readiness_check_summaries); + $ready_text_found = FALSE; + $status_checks_text = ''; + foreach ($readiness_check_summaries as $readiness_check_summary) { + $parent_element = $readiness_check_summary->getParent(); + if (str_contains($parent_element->getText(), 'Your site is ready for automatic updates.')) { + $ready_text_found = TRUE; + continue; + } + $description_list = $parent_element->find('css', 'ul'); + $this->assertNotEmpty($description_list); + $status_checks_text .= "\n" . $description_list->getText(); + } + $this->assertSame('', $status_checks_text); + $this->assertTrue($ready_text_found); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php new file mode 100644 index 000000000000..869c864f4406 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php @@ -0,0 +1,125 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\CommandExecutor; +use Drupal\auto_updates\UpdateStage; +use Drupal\fixture_manipulator\StageFixtureManipulator; +use Drupal\Tests\auto_updates\Traits\TestSetUpTrait; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use Drupal\Tests\package_manager\Traits\ComposerStagerTestTrait; +use Drupal\Tests\package_manager\Traits\FixtureManipulatorTrait; + +/** + * Base class for functional tests of the Automatic Updates module. + * + * @internal + */ +abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase { + + use AssertPreconditionsTrait; + use ComposerStagerTestTrait; + use FixtureManipulatorTrait; + use TestSetUpTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::SECURITY) + ->save(); + $this->mockActiveCoreVersion('9.8.0'); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + StageFixtureManipulator::handleTearDown(); + $service_ids = [ + // If auto_updates is installed, ensure any stage directory created + // during the test is cleaned up. + UpdateStage::class, + ]; + foreach ($service_ids as $service_id) { + if ($this->container->has($service_id)) { + $this->container->get($service_id)->destroy(TRUE); + } + } + parent::tearDown(); + } + + /** + * Mocks 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 version of core to mock. + */ + protected function mockActiveCoreVersion(string $version): void { + $this->config('update_test.settings') + ->set('system_info.#all.version', $version) + ->save(); + } + + /** + * Checks for available updates. + * + * Assumes that a user with appropriate permissions is logged in. + */ + protected function checkForUpdates(): void { + $this->drupalGet('/admin/reports/updates'); + $this->getSession()->getPage()->clickLink('Check manually'); + $this->checkForMetaRefresh(); + } + + /** + * Asserts that we are on the "update ready" form. + * + * @param string $target_version + * The target version of Drupal core. + */ + protected function assertUpdateReady(string $target_version): void { + $assert_session = $this->assertSession(); + $assert_session->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/'); + $assert_session->pageTextContainsOnce('Drupal core will be updated to ' . $target_version); + $button = $assert_session->buttonExists("Continue"); + $this->assertTrue($button->hasClass('button--primary')); + } + + /** + * Runs the console update command, which will trigger status checks. + */ + protected function runConsoleUpdateCommand(): void { + // Ensure that a valid test user agent cookie has been generated. + $this->prepareRequest(); + + $this->container->get(CommandExecutor::class) + ->create('--is-from-web') + ->setEnv([ + // Ensure that the command will boot up and run in the test site. + // @see drupal_valid_test_ua() + 'HTTP_USER_AGENT' => $this->getSession()->getCookie('SIMPLETEST_USER_AGENT'), + ]) + ->setWorkingDirectory($this->getDrupalRoot()) + ->mustRun(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php b/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php new file mode 100644 index 000000000000..a1900555de6e --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\Core\Url; + +/** + * Tests changes to the Available Updates report provided by the Update module. + * + * @group auto_updates + * @internal + */ +class AvailableUpdatesReportTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'auto_updates', + 'auto_updates_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $user = $this->createUser([ + 'administer site configuration', + 'administer software updates', + 'access administration pages', + 'access site reports', + ]); + $this->drupalLogin($user); + } + + /** + * Tests the Available Updates report links are correct. + */ + public function testReportLinks(): void { + $assert = $this->assertSession(); + $form_url = Url::fromRoute('update.report_update')->toString(); + + $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save(); + $fixture_directory = __DIR__ . '/../../../../package_manager/tests/fixtures/release-history'; + $this->setReleaseMetadata("$fixture_directory/drupal.9.8.1-security.xml"); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $assert->pageTextContains('Security update required! Update now'); + $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url); + $this->assertVersionIsListed('9.8.1'); + + $this->setReleaseMetadata("$fixture_directory/drupal.9.8.2-older-sec-release.xml"); + $this->mockActiveCoreVersion('9.7.0'); + $this->checkForUpdates(); + $assert->pageTextContains('Security update required! Update now'); + + $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url); + // Releases that will available on the form should link to the form. + $this->assertVersionIsListed('9.8.2'); + $this->assertVersionIsListed('9.7.1'); + // Releases that will not be available in the form should link to the + // project release page. + $this->assertVersionIsListed('9.8.1'); + + $this->setReleaseMetadata("$fixture_directory/drupal.9.8.2.xml"); + $this->checkForUpdates(); + $assert->pageTextContains('Update available Update now'); + $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url); + $this->assertVersionIsListed('9.8.2'); + } + + /** + * Asserts the version download link is correct. + * + * @param string $version + * The version. + */ + private function assertVersionIsListed(string $version): void { + $this->assertSession()->elementExists('css', "table.update .project-update__version:contains(\"$version\")"); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/ClickableHelpTest.php b/core/modules/auto_updates/tests/src/Functional/ClickableHelpTest.php new file mode 100644 index 000000000000..69b737b5cec1 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/ClickableHelpTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\Core\Url; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; + +/** + * Tests that links to online help in validation errors are clickable. + * + * @group auto_updates + * @internal + */ +class ClickableHelpTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'help', + 'package_manager_test_validation', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that a link to online help in a validation error is clickable. + */ + public function testHelpLinkClickable(): void { + $url = Url::fromRoute('help.page', ['name' => 'package_manager']) + ->toString(); + + $result = ValidationResult::createError([ + t('A problem was found! <a href=":url">Read all about it.</a>', [':url' => $url]), + ]); + TestSubscriber::setTestResult([$result], StatusCheckEvent::class); + + $this->drupalLogin($this->createUser([ + 'administer site configuration', + 'administer software updates', + ])); + $this->drupalGet('admin/reports/status'); + // Status checks were run when modules were installed, and are now cached, + // so we need to re-run the status checks to see our new result. + // @see auto_updates_modules_installed() + $this->clickLink('Rerun readiness checks'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('A problem was found! Read all about it.'); + $assert_session->linkExists('Read all about it.'); + $assert_session->linkByHrefExists($url); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/ComposerStagerOperationFailureTest.php b/core/modules/auto_updates/tests/src/Functional/ComposerStagerOperationFailureTest.php new file mode 100644 index 000000000000..16d28964b2dc --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/ComposerStagerOperationFailureTest.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\package_manager_bypass\LoggingBeginner; +use Drupal\package_manager_bypass\LoggingCommitter; +use Drupal\package_manager_bypass\NoOpStager; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; +use PhpTuf\ComposerStager\API\Exception\LogicException; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class ComposerStagerOperationFailureTest extends UpdaterFormTestBase { + + /** + * Tests Composer operation failure is handled properly. + * + * @param string $exception_class + * The exception class. + * @param string $service_class + * The Composer Stager service which should throw an exception. + * + * @dataProvider providerComposerOperationFailure + */ + public function testComposerOperationFailure(string $exception_class, string $service_class): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + $page->hasButton('Update to 9.8.1'); + + // Make the specified Composer Stager operation class throw an exception. + $message = $this->createComposeStagerMessage("Failure from inside $service_class"); + $exception = new $exception_class($message); + call_user_func([$service_class, 'setException'], $exception); + + // Start the update. + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + // We can't continue the update after an error in the committer. + if ($service_class === LoggingCommitter::class) { + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $this->clickLink('the error page'); + $assert_session->statusCodeEquals(200); + $assert_session->statusMessageContains('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.'); + return; + } + $this->clickLink('the error page'); + $assert_session->statusMessageContains($exception->getMessage()); + + // Make the same Composer Stager operation class NOT throw an exception. + call_user_func([$service_class, 'setException'], NULL); + + // Set up the update to 9.8.1 again as the stage gets destroyed after an + // exception occurs. + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + // Stage should be automatically deleted when an error occurs. + $assert_session->buttonNotExists('Delete existing update'); + // This ensures that we can still update after the exception no longer + // exists. + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->statusMessageContains('Update complete!'); + } + + /** + * Data provider for testComposerOperationFailure(). + * + * @return string[][] + * The test cases. + */ + public function providerComposerOperationFailure(): array { + return [ + 'LogicException from Beginner' => [LogicException::class, LoggingBeginner::class], + 'LogicException from Stager' => [LogicException::class, NoOpStager::class], + 'InvalidArgumentException from Stager' => [InvalidArgumentException::class, NoOpStager::class], + 'LogicException from Committer' => [LogicException::class, LoggingCommitter::class], + ]; + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/DeleteExistingUpdateTest.php b/core/modules/auto_updates/tests/src/Functional/DeleteExistingUpdateTest.php new file mode 100644 index 000000000000..4e995402ffab --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/DeleteExistingUpdateTest.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates_test\Datetime\TestTime; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\system\SystemManager; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class DeleteExistingUpdateTest extends UpdaterFormTestBase { + + /** + * Tests deleting an existing update. + */ + public function testDeleteExistingUpdate(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $conflict_message = 'Cannot begin an update because another Composer operation is currently in progress.'; + $cancelled_message = 'The update was successfully cancelled.'; + + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + + // Confirm we are on the confirmation page. + $this->assertUpdateReady('9.8.1'); + $assert_session->buttonExists('Continue'); + + // If we try to return to the start page, we should be redirected back to + // the confirmation page. + $this->drupalGet('/admin/modules/update'); + $this->assertUpdateReady('9.8.1'); + + // Delete the existing update. + $page->pressButton('Cancel update'); + $assert_session->addressEquals('/admin/reports/updates/update'); + $assert_session->pageTextContains($cancelled_message); + $assert_session->pageTextNotContains($conflict_message); + + // Ensure we can start another update after deleting the existing one. + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + + // Confirm we are on the confirmation page. + $this->assertUpdateReady('9.8.1'); + $this->assertUpdateStagedTimes(2); + $assert_session->buttonExists('Continue'); + + // Log in as another administrative user and ensure that we cannot begin an + // update because the previous session already started one. + $account = $this->createUser([], NULL, TRUE); + $this->drupalLogin($account); + $this->drupalGet('/admin/reports/updates/update'); + $assert_session->pageTextContains($conflict_message); + $this->assertNoUpdateButtons(); + // We should be able to delete the previous update, then start a new one. + $page->pressButton('Delete existing update'); + $assert_session->pageTextContains('Staged update deleted'); + $assert_session->pageTextNotContains($conflict_message); + // Before pressing the button, create a new stage fixture manipulator. + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady('9.8.1'); + + // Stop execution during pre-apply. This should make Package Manager think + // the staged changes are being applied and raise an error if we try to + // cancel the update. + TestSubscriber1::setExit(PreApplyEvent::class); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $page->clickLink('the error page'); + $page->pressButton('Cancel update'); + // The exception should have been caught and displayed in the messages area. + $assert_session->statusCodeEquals(200); + $destroy_error = 'Cannot destroy the stage directory while it is being applied to the active directory.'; + $assert_session->pageTextContains($destroy_error); + $assert_session->pageTextNotContains($cancelled_message); + + // We should get the same error if we log in as another user and try to + // delete the staged update. + $user = $this->createUser([ + 'administer software updates', + 'access site in maintenance mode', + ]); + $this->drupalLogin($user); + $this->drupalGet('/admin/reports/updates/update'); + $assert_session->pageTextContains($conflict_message); + $page->pressButton('Delete existing update'); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains($destroy_error); + $assert_session->pageTextNotContains('Staged update deleted'); + + // Two hours later, Package Manager should consider the stage to be stale, + // allowing the staged update to be deleted. + TestTime::setFakeTimeByOffset('+2 hours'); + $this->getSession()->reload(); + $assert_session->pageTextContains($conflict_message); + $page->pressButton('Delete existing update'); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains('Staged update deleted'); + + // If a legitimate error is raised during pre-apply, we should be able to + // delete the staged update right away. + $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($results, PreApplyEvent::class); + // Before pressing the button, create a new stage fixture manipulator. + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady('9.8.1'); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $page->clickLink('the error page'); + $page->pressButton('Cancel update'); + $assert_session->pageTextContains($cancelled_message); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/ErrorMessageOnStageDestroyTest.php b/core/modules/auto_updates/tests/src/Functional/ErrorMessageOnStageDestroyTest.php new file mode 100644 index 000000000000..a10e8d62ec57 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/ErrorMessageOnStageDestroyTest.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates\UpdateStage; + +/** + * Tests error message when the stage the user is interacting with is destroyed. + * + * @group auto_updates + * @internal + */ +class ErrorMessageOnStageDestroyTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates', 'auto_updates_test']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml'); + $this->mockActiveCoreVersion('9.8.0'); + + $this->drupalLogin($this->createUser([ + 'administer site configuration', + 'administer software updates', + 'access site reports', + ])); + } + + /** + * Tests error message on previous stage destroy. + */ + public function testMessagesOnStageDestroy(): void { + $this->getStageFixtureManipulator() + ->setCorePackageVersion('9.8.1'); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + $assert_session->buttonExists('Update to 9.8.1'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady('9.8.1'); + $stage = $this->container->get(UpdateStage::class); + $random_message = $this->randomString(); + // @see \Drupal\Tests\package_manager\Kernel\StageTest::testStoreDestroyInfo() + // @see \Drupal\auto_updates\CronUpdateRunner::performUpdate() + $stage->destroy(TRUE, t($random_message)); + $this->checkForMetaRefresh(); + $page->pressButton('Continue'); + $assert_session->pageTextContains($random_message); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/GenericTest.php b/core/modules/auto_updates/tests/src/Functional/GenericTest.php new file mode 100644 index 000000000000..bba4e2a7bae4 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/GenericTest.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for auto_updates. + * + * @group auto_updates + */ +class GenericTest extends GenericModuleTestBase {} diff --git a/core/modules/auto_updates/tests/src/Functional/HelpPageTest.php b/core/modules/auto_updates/tests/src/Functional/HelpPageTest.php new file mode 100644 index 000000000000..5956c83c6a36 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/HelpPageTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; + +/** + * @group auto_updates + * @internal + */ +class HelpPageTest extends AutoUpdatesFunctionalTestBase { + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'help', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that the help page for Automatic Updates loads correctly. + */ + public function testHelpPage(): void { + $permissions = [ + 'access administration pages', + 'access help pages', + ]; + $user = $this->createUser($permissions); + $this->drupalLogin($user); + $this->drupalGet('/admin/help/auto_updates'); + + $assert_session = $this->assertSession(); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains('Automatic Updates'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/NoUpdateButtonsTest.php b/core/modules/auto_updates/tests/src/Functional/NoUpdateButtonsTest.php new file mode 100644 index 000000000000..a17a3c418967 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/NoUpdateButtonsTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class NoUpdateButtonsTest extends UpdaterFormTestBase { + + /** + * Data provider for URLs to the update form. + * + * @return string[][] + * The test cases. + */ + public function providerUpdateFormReferringUrl(): array { + return [ + 'Modules page' => ['/admin/modules/update'], + 'Reports page' => ['/admin/reports/updates/update'], + ]; + } + + /** + * Tests that the form doesn't display any buttons if Drupal is up-to-date. + * + * @todo Mark this test as skipped if the web server is PHP's built-in, single + * threaded server in https://drupal.org/i/3348251. + * + * @param string $update_form_url + * The URL of the update form to visit. + * + * @dataProvider providerUpdateFormReferringUrl + */ + public function testFormNotDisplayedIfAlreadyCurrent(string $update_form_url): void { + $this->mockActiveCoreVersion('9.8.1'); + $this->checkForUpdates(); + + $this->drupalGet($update_form_url); + + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('No update available'); + $this->assertNoUpdateButtons(); + } + + /** + * Tests that updating to a different minor version isn't supported. + * + * @param string $update_form_url + * The URL of the update form to visit. + * + * @dataProvider providerUpdateFormReferringUrl + */ + public function testMinorVersionUpdateNotSupported(string $update_form_url): void { + $this->mockActiveCoreVersion('9.7.1'); + $this->checkForUpdates(); + + $this->drupalGet($update_form_url); + + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Updates were found, but they must be performed manually. See the list of available updates for more information.'); + $this->clickLink('the list of available updates'); + $assert_session->elementExists('css', 'table.update'); + $this->assertNoUpdateButtons(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/PreUpdateTest.php b/core/modules/auto_updates/tests/src/Functional/PreUpdateTest.php new file mode 100644 index 000000000000..68b4e6c4b185 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/PreUpdateTest.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class PreUpdateTest extends UpdaterFormTestBase { + + /** + * Tests status checks are displayed when there is no update available. + */ + public function testStatusCheckFailureWhenNoUpdateExists() { + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.1'); + $message = t("You've not experienced Shakespeare until you have read him in the original Klingon."); + $result = ValidationResult::createError([$message]); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + $this->checkForUpdates(); + $this->drupalGet('/admin/reports/updates/update'); + $assert_session->pageTextContains('No update available'); + $assert_session->pageTextContains($message->render()); + } + + /** + * Checks RC releases of the next minor are available on the form. + */ + public function testNextMinorRc(): void { + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml'); + $this->mockActiveCoreVersion('9.7.0'); + $this->config('auto_updates.settings') + ->set('allow_core_minor_updates', TRUE) + ->save(); + $this->checkForUpdates(); + $this->drupalGet('/admin/reports/updates/update'); + $assert_session = $this->assertSession(); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-recommended', '9.8.0-rc1', FALSE, 'Latest version of Drupal 9.8 (next minor):'); + $assert_session->pageTextContainsOnce('Currently installed: 9.7.0 (Up to date)'); + } + + /** + * Checks Beta releases of the next minor are not available on the form. + */ + public function testNextMinorBeta(): void { + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml'); + $this->mockActiveCoreVersion('9.7.0'); + $this->config('auto_updates.settings') + ->set('allow_core_minor_updates', TRUE) + ->save(); + $this->checkForUpdates(); + $this->drupalGet('/admin/reports/updates/update'); + $assert_session = $this->assertSession(); + $assert_session->statusMessageContains('No update available'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/StagedDatabaseUpdateTest.php b/core/modules/auto_updates/tests/src/Functional/StagedDatabaseUpdateTest.php new file mode 100644 index 000000000000..ffd5861cd73a --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/StagedDatabaseUpdateTest.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager_test_validation\StagedDatabaseUpdateValidator; +use Drupal\system\SystemManager; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class StagedDatabaseUpdateTest extends UpdaterFormTestBase { + + /** + * Data provider for testStagedDatabaseUpdates(). + * + * @return bool[][] + * The test cases. + */ + public function providerStagedDatabaseUpdates(): array { + return [ + 'maintenance mode on' => [TRUE], + 'maintenance mode off' => [FALSE], + ]; + } + + /** + * Tests the update form when staged modules have database updates. + * + * @param bool $maintenance_mode_on + * Whether the site should be in maintenance mode at the beginning of the + * update process. + * + * @dataProvider providerStagedDatabaseUpdates + */ + public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->container->get('theme_installer') + ->install(['auto_updates_theme_with_updates']); + $cached_message = $this->setAndAssertCachedMessage(); + + $state = $this->container->get('state'); + $state->set('system.maintenance_mode', $maintenance_mode_on); + + // Flag a warning, which will not block the update but should be displayed + // on the updater form. + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $messages = reset($expected_results)->messages; + + StagedDatabaseUpdateValidator::setExtensionsWithUpdates([ + 'system', + 'auto_updates_theme_with_updates', + ]); + + $page = $this->getSession()->getPage(); + $this->drupalGet('/admin/modules/update'); + // The warning should be visible. + $assert_session = $this->assertSession(); + $assert_session->pageTextContains((reset($messages))->render()); + $assert_session->pageTextNotContains($cached_message->render()); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + $this->assertUpdateReady('9.8.1'); + // Simulate a staged database update in the auto_updates_test module. + // We must do this after the update has started, because the pending updates + // validator will prevent an update from starting. + $state->set('auto_updates_test.new_update', TRUE); + // The warning from the updater form should be repeated, and we should see + // a warning about pending database updates. Once the staged changes have + // been applied, we should be redirected to update.php, where neither + // warning should be visible. + $assert_session->pageTextContains((reset($messages))->render()); + + // Ensure that a list of pending database updates is visible, along with a + // short explanation, in the warning messages. + $possible_update_message = 'Database updates have been detected in the following extensions.<ul><li>System</li><li>Automatic Updates Theme With Updates</li></ul>'; + $warning_messages = $assert_session->elementExists('css', 'div[data-drupal-messages] div[aria-label="Warning message"]'); + $this->assertStringContainsString($possible_update_message, $warning_messages->getHtml()); + if ($maintenance_mode_on === TRUE) { + $assert_session->fieldNotExists('maintenance_mode'); + } + else { + $assert_session->checkboxChecked('maintenance_mode'); + } + $assert_session->pageTextNotContains($cached_message->render()); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + // Confirm that the site was in maintenance before the update was applied. + // @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent() + $this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode')); + // Confirm the site remains in maintenance more when redirected to + // update.php. + $this->assertTrue($state->get('system.maintenance_mode')); + $assert_session->pageTextContainsOnce('An error has occurred.'); + $assert_session->pageTextContainsOnce('Continue to the error page'); + $page->clickLink('the error page'); + $assert_session->pageTextContains('Some modules have database schema updates to install. You should run the database update script immediately.'); + $assert_session->linkExists('database update script'); + $assert_session->linkByHrefExists('/update.php'); + $page->clickLink('database update script'); + $assert_session->addressEquals('/update.php'); + $assert_session->pageTextNotContains($cached_message->render()); + $assert_session->pageTextNotContains((reset($messages))->render()); + $assert_session->pageTextNotContains($possible_update_message); + $this->assertTrue($state->get('system.maintenance_mode')); + $page->clickLink('Continue'); + // @see auto_updates_update_1191934() + $assert_session->pageTextContains('Dynamic auto_updates_update_1191934'); + $page->clickLink('Apply pending updates'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('Updates were attempted.'); + // Confirm the site was returned to the original maintenance module state. + $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on); + $assert_session->pageTextNotContains($cached_message->render()); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/StatusCheckFailureEmailTest.php b/core/modules/auto_updates/tests/src/Functional/StatusCheckFailureEmailTest.php new file mode 100644 index 000000000000..3faffe58217e --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/StatusCheckFailureEmailTest.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\StatusCheckMailer; +use Drupal\auto_updates_test\Datetime\TestTime; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\Core\Url; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Traits\EmailNotificationsTestTrait; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use Drupal\Tests\Traits\Core\CronRunTrait; + +/** + * Tests status check failure notification emails during cron runs. + * + * @group auto_updates + * @covers \Drupal\auto_updates\StatusCheckMailer + * @internal + */ +class StatusCheckFailureEmailTest extends AutoUpdatesFunctionalTestBase { + + use CronRunTrait; + use EmailNotificationsTestTrait; + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'package_manager_test_validation', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + // Simulate that we're already fully up to date. + $this->mockActiveCoreVersion('9.8.1'); + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings') + ->set('unattended', [ + 'level' => CronUpdateRunner::SECURITY, + 'method' => 'console', + ]) + ->save(); + $this->setUpEmailRecipients(); + + // Allow stored available update data to live for a very, very long time. + // By default, the data expires after one day, but this test runs cron many + // times, with a simulated two hour interval between each run (see + // ::runCron()). Without this long grace period, all the cron runs in this + // test would need to run on the same "day", to prevent certain validators + // from breaking this test due to available update data being irretrievable. + $this->config('update.settings') + ->set('check.interval_days', 30) + ->save(); + } + + /** + * Asserts that a certain number of failure notifications has been sent. + * + * @param int $expected_count + * The expected number of failure notifications that should have been sent. + */ + private function assertSentMessagesCount(int $expected_count): void { + $sent_messages = $this->getMails([ + 'id' => 'auto_updates_status_check_failed', + ]); + $this->assertCount($expected_count, $sent_messages); + } + + /** + * Tests that status check failures will trigger emails in some situations. + */ + public function testFailureNotifications(): void { + // No messages should have been sent yet. + $this->assertSentMessagesCount(0); + + $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$error], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + + $url = Url::fromRoute('system.status') + ->setAbsolute() + ->toString(); + + $expected_body = <<<END +Your site has failed some readiness checks for automatic updates and may not be able to receive automatic updates until further action is taken. Visit $url for more information. +END; + $this->assertMessagesSent('Automatic updates readiness checks failed', $expected_body); + + // Running cron again should not trigger another email (i.e., each + // recipient has only been emailed once) since the results are unchanged. + $recipient_count = count($this->emailRecipients); + $this->assertGreaterThan(0, $recipient_count); + $sent_messages_count = $recipient_count; + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If a different error is flagged, they should be emailed again. + $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$error], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + + // If we flag the same error, but a new warning, they should not be emailed + // again because we ignore warnings by default, and they've already been + // emailed about this error. + $results = [ + $error, + $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If only a warning is flagged, they should not be emailed again because + // we ignore warnings by default. + $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING); + TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If we stop ignoring warnings, they should be emailed again because we + // clear the stored results if the relevant configuration is changed. + $config = $this->config('auto_updates.settings'); + $config->set('status_check_mail', StatusCheckMailer::ALL)->save(); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + + // If we flag a different warning, they should be emailed again. + $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING); + TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + + // If we flag multiple warnings, they should be emailed again because the + // number of results has changed, even if the severity hasn't. + $warnings = [ + $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + TestSubscriber1::setTestResult($warnings, StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + + // If we flag an error and a warning, they should be emailed again because + // the severity has changed, even if the number of results hasn't. + $results = [ + $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + $this->createValidationResult(SystemManager::REQUIREMENT_ERROR), + ]; + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + + // If we change the order of the results, they should not be emailed again + // because we are handling the possibility of the results being in a + // different order. + $results = array_reverse($results); + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If we disable notifications entirely, they should not be emailed even + // if a different error is flagged. + $config->set('status_check_mail', StatusCheckMailer::DISABLED)->save(); + $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$error], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If we re-enable notifications and go back to ignoring warnings, they + // should not be emailed if a new warning is flagged. + $config->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)->save(); + $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING); + TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If we disable unattended updates entirely and flag a new error, they + // should not be emailed. + $config->set('unattended.level', CronUpdateRunner::DISABLED)->save(); + $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$error], StatusCheckEvent::class); + $this->runConsoleUpdateCommand(); + $this->assertSentMessagesCount($sent_messages_count); + + // If we re-enable unattended updates, they should be emailed again, even if + // the results haven't changed. + $config->set('unattended.level', CronUpdateRunner::SECURITY)->save(); + $this->runConsoleUpdateCommand(); + $sent_messages_count += $recipient_count; + $this->assertSentMessagesCount($sent_messages_count); + } + + /** + * {@inheritdoc} + */ + protected function runConsoleUpdateCommand(): void { + static $total_delay = 0; + // Status checks don't run more than once an hour, so pretend that 61 + // minutes have elapsed since the last run. + $total_delay += 61; + TestTime::setFakeTimeByOffset("+$total_delay minutes"); + + parent::runConsoleUpdateCommand(); + + // Since the terminal command that sent the emails doesn't use the same + // container as this test, we need to reset the state cache to get + // information about the sent emails. + $this->container->get('state')->resetCache(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/StatusCheckTest.php b/core/modules/auto_updates/tests/src/Functional/StatusCheckTest.php new file mode 100644 index 000000000000..c045ee3a3261 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/StatusCheckTest.php @@ -0,0 +1,760 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Behat\Mink\Element\NodeElement; +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\StatusCheckMailer; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\auto_updates_test_status_checker\EventSubscriber\TestSubscriber2; +use Drupal\Core\Url; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use Drupal\Tests\Traits\Core\CronRunTrait; + +/** + * Tests status checks. + * + * @group auto_updates + * @internal + */ +class StatusCheckTest extends AutoUpdatesFunctionalTestBase { + + use CronRunTrait; + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * A user who can view the status report. + * + * @var \Drupal\user\Entity\User + */ + protected $reportViewerUser; + + /** + * A user who can view the status report and run status checks. + * + * @var \Drupal\user\Entity\User + */ + protected $checkerRunnerUser; + + /** + * The test checker. + * + * @var \Drupal\auto_updates_test\EventSubscriber\TestSubscriber1 + */ + protected $testChecker; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'package_manager_test_validation', + 'block', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml'); + $this->mockActiveCoreVersion('9.8.1'); + + $this->reportViewerUser = $this->createUser([ + 'administer site configuration', + 'access administration pages', + 'administer blocks', + ]); + $this->checkerRunnerUser = $this->createUser([ + 'administer site configuration', + 'administer software updates', + 'access administration pages', + 'access site in maintenance mode', + 'administer modules', + 'administer blocks', + ]); + $this->drupalLogin($this->reportViewerUser); + } + + /** + * Tests status checks are displayed after Automatic Updates is installed. + * + * @dataProvider providerTestModuleFormInstallDisplay + */ + public function testModuleFormInstallDisplay(int $results_severity): void { + // Uninstall Automatic Updates as it is installed in TestBase setup(). + $this->container->get('module_installer')->uninstall(['auto_updates']); + $expected_result = $this->createValidationResult($results_severity); + TestSubscriber::setTestResult([$expected_result], StatusCheckEvent::class); + + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/modules'); + $page = $this->getSession()->getPage(); + $page->checkField('modules[auto_updates][enable]'); + $page->pressButton('Install'); + // Confirm installing the experimental module. + $page->pressButton('Continue'); + // Cron Updates will always be disabled on installation as per + // auto_updates.settings.yml . + $session = $this->assertSession(); + $session->pageTextNotContains($expected_result->messages[0]->render()); + $session->linkExists('See status report for more details.'); + } + + /** + * Provides data for testModuleFormInstallDisplay. + */ + public function providerTestModuleFormInstallDisplay(): array { + return [ + 'Error' => [ + SystemManager::REQUIREMENT_ERROR, + ], + 'Warning' => [ + SystemManager::REQUIREMENT_WARNING, + ], + ]; + } + + /** + * Tests status checks on status report page. + */ + public function testStatusChecksOnStatusReport(): void { + $assert = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + + // If the site is ready for updates, the users will see the same output + // regardless of whether the user has permission to run updates. + $this->drupalLogin($this->reportViewerUser); + $this->checkForUpdates(); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + // Confirm a user without the permission to run status checks does not + // have a link to run the checks when the checks need to be run again. + /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ + $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates'); + $key_value->delete('status_check_last_run'); + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + // Confirm a user with the permission to run status checks does have a link + // to run the checks when the checks need to be run again. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + /** @var \Drupal\package_manager\ValidationResult[] $expected_results */ + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + + // Run the status checks. + $this->clickLink('Rerun readiness checks'); + $assert->statusCodeEquals(200); + // Confirm redirect back to status report page. + $assert->addressEquals('/admin/reports/status'); + // Assert that when the runners are run manually the message that updates + // will not be performed because of errors is displayed on the top of the + // page in message. + $assert->pageTextMatchesCount(2, '/' . preg_quote(static::$errorsExplanation) . '/'); + $this->assertErrors($expected_results, TRUE); + + // Confirm a user without permission to run the checks sees the same error. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertErrors($expected_results); + + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('/admin/reports/status'); + + $expected_results = [ + 'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR), + 'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $page->clickLink('Rerun readiness checks'); + // We should see the summaries and messages, even if there's only 1 message. + $this->assertErrors([$expected_results['error']], TRUE); + $this->assertWarnings([$expected_results['warning']], TRUE); + + // If there's a result with only one message, but no summary, ensure that + // message is displayed. + $result = ValidationResult::createError([t('A lone message, with no summary.')]); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + $page->clickLink('Rerun readiness checks'); + $this->assertErrors([$result], TRUE); + + $expected_results = [ + 'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2), + 'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2), + ]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $page->clickLink('Rerun readiness checks'); + // Confirm that both messages and summaries will be displayed when there are + // multiple messages. + $this->assertErrors([$expected_results['error']], TRUE); + $this->assertWarnings([$expected_results['warning']], TRUE); + + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $page->clickLink('Rerun readiness checks'); + $assert->pageTextContainsOnce('Update readiness checks'); + // Confirm that warnings will display on the status report if there are no + // errors. + $this->assertWarnings($expected_results, TRUE); + + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $page->clickLink('Rerun readiness checks'); + $assert->pageTextContainsOnce('Update readiness checks'); + $this->assertWarnings($expected_results, TRUE); + } + + /** + * Data provider for URLs to the admin page. + * + * These particular admin routes are tested as status checks are disabled on + * certain routes but not on these. + * + * @see \Drupal\auto_updates\Routing\RouteSubscriber::alterRoutes() + * + * @return string[][] + * The test cases. + */ + public function providerAdminRoutes(): array { + return [ + 'Structure Page' => ['system.admin_structure'], + 'Update settings Page' => ['update.settings'], + ]; + } + + /** + * Tests status check results on admin pages. + * + * @param string $admin_route + * The admin route to check. + * + * @dataProvider providerAdminRoutes + */ + public function testStatusChecksOnAdminPages(string $admin_route): void { + $assert = $this->assertSession(); + + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + + // If site is ready for updates no message will be displayed on admin pages. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalGet(Url::fromRoute($admin_route)); + + $assert->statusMessageNotExists(); + + // Confirm a user without the permission to run status checks does not have + // a link to run the checks when the checks need to be run again. + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ + $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates'); + $key_value->delete('status_check_last_run'); + // A user without the permission to run the checkers will not see a message + // on other pages if the checkers need to be run again. + $this->drupalGet(Url::fromRoute($admin_route)); + $assert->statusMessageNotExists(); + + // Confirm that a user with the correct permission can also run the checkers + // on another admin page. + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet(Url::fromRoute($admin_route)); + $assert->statusMessageContains('Your site has not recently run an update readiness check. Rerun readiness checks now.'); + $this->clickLink('Rerun readiness checks now.'); + $assert->addressEquals(Url::fromRoute($admin_route)); + $assert->pageTextContainsOnce((string) $expected_results[0]->summary); + + $expected_results = [ + '1 error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR), + '1 warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $this->runStatusChecks(); + $this->drupalGet(Url::fromRoute($admin_route)); + $assert->pageTextContainsOnce(static::$errorsExplanation); + // Confirm on admin pages that the summary will be displayed. + $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1 error']->severity); + $assert->pageTextContainsOnce((string) $expected_results['1 error']->summary); + $assert->pageTextNotContains($expected_results['1 error']->messages[0]->render()); + // Warnings are not displayed on admin pages if there are any errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1 warning']->severity); + $assert->pageTextNotContains($expected_results['1 warning']->messages[0]->render()); + $assert->pageTextNotContains($expected_results['1 warning']->summary->render()); + + // Confirm the status check event is not dispatched on every admin page + // load. + $unexpected_results = [ + '2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2), + '2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2), + ]; + TestSubscriber1::setTestResult($unexpected_results, StatusCheckEvent::class); + $this->drupalGet(Url::fromRoute($admin_route)); + $assert->pageTextNotContains($unexpected_results['2 errors']->summary->render()); + $assert->pageTextContainsOnce((string) $expected_results['1 error']->summary->render()); + $assert->pageTextNotContains($unexpected_results['2 warnings']->summary->render()); + $assert->pageTextNotContains($expected_results['1 warning']->messages[0]->render()); + + // Confirm the updated results will be shown when status checks are run + // again. + $this->runStatusChecks(); + $expected_results = $unexpected_results; + $this->drupalGet(Url::fromRoute($admin_route)); + // Confirm on admin pages only the error summary will be displayed if there + // is more than 1 error. + $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['2 errors']->severity); + $assert->pageTextNotContains($expected_results['2 errors']->messages[0]->render()); + $assert->pageTextNotContains($expected_results['2 errors']->messages[1]->render()); + $assert->pageTextContainsOnce($expected_results['2 errors']->summary->render()); + $assert->pageTextContainsOnce(static::$errorsExplanation); + // Warnings are not displayed on admin pages if there are any errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['2 warnings']->severity); + $assert->pageTextNotContains($expected_results['2 warnings']->messages[0]->render()); + $assert->pageTextNotContains($expected_results['2 warnings']->messages[1]->render()); + $assert->pageTextNotContains($expected_results['2 warnings']->summary->render()); + + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $this->runStatusChecks(); + $this->drupalGet(Url::fromRoute($admin_route)); + // Confirm that the warnings summary is displayed on admin pages if there + // are no errors. + $assert->pageTextNotContains(static::$errorsExplanation); + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->severity); + $assert->pageTextNotContains($expected_results[0]->messages[0]->render()); + $assert->pageTextNotContains($expected_results[0]->messages[1]->render()); + $assert->pageTextContainsOnce(static::$warningsExplanation); + $assert->pageTextContainsOnce($expected_results[0]->summary->render()); + + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $this->runStatusChecks(); + $this->drupalGet(Url::fromRoute($admin_route)); + $assert->pageTextNotContains(static::$errorsExplanation); + // Confirm that a single warning is displayed and not the summary on admin + // pages if there is only 1 warning and there are no errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->severity); + $assert->pageTextContainsOnce(static::$warningsExplanation); + $assert->pageTextContainsOnce((string) $expected_results[0]->summary->render()); + $assert->pageTextNotContains($expected_results[0]->messages[0]->render()); + + // Confirm status check messages are not displayed when cron updates are + // disabled. + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::DISABLED) + ->save(); + $this->drupalGet('admin/structure'); + $this->checkForMetaRefresh(); + $assert->pageTextNotContains(static::$warningsExplanation); + $assert->pageTextNotContains($expected_results[0]->messages[0]->render()); + } + + /** + * Tests installing a module with a checker before installing Automatic Updates. + */ + public function testStatusCheckAfterInstall(): void { + $assert = $this->assertSession(); + $this->drupalLogin($this->checkerRunnerUser); + $this->container->get('module_installer')->uninstall(['auto_updates']); + + $this->drupalGet('admin/reports/status'); + $assert->pageTextNotContains('Update readiness checks'); + + // We have to install the auto_updates_test module because it provides + // the functionality to retrieve our fake release history metadata. + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::SECURITY) + ->save(); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber2::setTestResult($expected_results, StatusCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates_test_status_checker']); + $this->drupalGet('admin/structure'); + $assert->pageTextContainsOnce((string) $expected_results[0]->summary->render()); + + // Confirm that installing a module runs the checkers, even if the new + // module does not provide any validators. + $previous_results = $expected_results; + $expected_results = [ + '2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2), + '2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2), + ]; + TestSubscriber2::setTestResult($expected_results, StatusCheckEvent::class); + $this->container->get('module_installer')->install(['help']); + // Check for messages on 'admin/structure' instead of the status report, + // because validators will be run if needed on the status report. + $this->drupalGet('admin/structure'); + // Confirm that new checker messages are displayed. + $assert->pageTextNotContains($previous_results[0]->messages[0]->render()); + $assert->pageTextNotContains($expected_results['2 errors']->messages[0]->render()); + $assert->pageTextContainsOnce($expected_results['2 errors']->summary->render()); + } + + /** + * Tests that checker message for an uninstalled module is not displayed. + */ + public function testStatusCheckUninstall(): void { + $assert = $this->assertSession(); + $this->drupalLogin($this->checkerRunnerUser); + + $expected_results_1 = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($expected_results_1, StatusCheckEvent::class); + $expected_results_2 = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber2::setTestResult($expected_results_2, StatusCheckEvent::class); + $this->container->get('module_installer')->install([ + 'auto_updates', + 'auto_updates_test', + 'auto_updates_test_status_checker', + ]); + // Check for message on 'admin/structure' instead of the status report + // because checkers will be run if needed on the status report. + $this->drupalGet('admin/structure'); + $assert->pageTextContainsOnce($expected_results_1[0]->summary->render()); + $assert->pageTextContainsOnce($expected_results_2[0]->summary->render()); + + // Confirm that when on of the module is uninstalled the other module's + // checker result is still displayed. + $this->container->get('module_installer')->uninstall(['auto_updates_test_status_checker']); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains($expected_results_2[0]->summary->render()); + $assert->pageTextContainsOnce($expected_results_1[0]->summary->render()); + + // Confirm that when on of the module is uninstalled the other module's + // checker result is still displayed. + $this->container->get('module_installer')->uninstall(['auto_updates_test']); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains($expected_results_2[0]->messages[0]->render()); + $assert->pageTextNotContains($expected_results_1[0]->messages[0]->render()); + } + + /** + * Tests that stored validation results are deleted after an update. + */ + public function testStoredResultsClearedAfterUpdate(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->drupalLogin($this->checkerRunnerUser); + + // The current release is 9.8.1 (see ::setUp()), so ensure we're on an older + // version. + $this->mockActiveCoreVersion('9.8.0'); + + // Flag a validation error, whose summary will be displayed in the messages + // area. + $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + $message = $results[0]->summary; + + $this->container->get('module_installer')->install([ + 'auto_updates', + 'auto_updates_test', + ]); + $this->checkForUpdates(); + // The error should be persistently visible, even after the checker stops + // flagging it. + $this->drupalGet('/admin/structure'); + $assert_session->pageTextContains($message->render()); + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + $this->getSession()->reload(); + $assert_session->pageTextContains($message->render()); + + // Do the update; we don't expect any errors or special conditions to appear + // during it. The Update button is displayed because the form does its own + // status check (without storing the results), and the checker is no + // longer raising an error. + $this->drupalGet('/admin/modules/update'); + $assert_session->buttonExists('Update to 9.8.1'); + // Ensure that the previous results are still displayed on another admin + // page, to confirm that the updater form is not discarding the previous + // results by doing its checks. + $this->drupalGet('/admin/structure'); + $assert_session->pageTextContains($message->render()); + // Proceed with the update. + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady('9.8.1'); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('Update complete!'); + + // The warning should not be visible anymore. + $this->drupalGet('/admin/structure'); + $assert_session->pageTextNotContains($message->render()); + } + + /** + * Tests that stored results are deleted after certain config changes. + */ + public function testStoredResultsClearedAfterConfigChanges(): void { + $this->drupalLogin($this->checkerRunnerUser); + + // Flag a validation error, whose summary will be displayed in the messages + // area. + $result = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + $message = $result->summary; + + $this->container->get('module_installer')->install([ + 'auto_updates', + 'auto_updates_test', + ]); + $this->container = $this->container->get('kernel')->getContainer(); + + // The error should be persistently visible, even after the checker stops + // flagging it. + $this->drupalGet('/admin/structure'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains($message->render()); + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + $session = $this->getSession(); + $session->reload(); + $assert_session->pageTextContains($message->render()); + + $config = $this->config('auto_updates.settings'); + // If we disable notifications, stored results should not be cleared. + $config->set('status_check_mail', StatusCheckMailer::DISABLED)->save(); + $session->reload(); + $assert_session->pageTextContains($message->render()); + + // If we re-enable them, though, they should be cleared. + $config->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)->save(); + $session->reload(); + $assert_session->pageTextNotContains($message->render()); + $no_results_message = 'Your site has not recently run an update readiness check.'; + $assert_session->pageTextContains($no_results_message); + + // If we flag an error again, and keep notifications enabled but change + // their sensitivity level, the stored results should be cleared. + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + $session->getPage()->clickLink('Rerun readiness checks now'); + $this->drupalGet('/admin/structure'); + $assert_session->pageTextContains($message->render()); + $config->set('status_check_mail', StatusCheckMailer::ALL)->save(); + $session->reload(); + $assert_session->pageTextNotContains($message->render()); + $assert_session->pageTextContains($no_results_message); + } + + /** + * Tests that the status report shows cached status check results. + */ + public function testStatusReportShowsCachedResults(): void { + $session = $this->getSession(); + $this->drupalLogin($this->checkerRunnerUser); + + $this->container->get('module_installer')->install([ + 'auto_updates', + 'auto_updates_test', + ]); + $this->container = $this->container->get('kernel')->getContainer(); + + // Clear stored results that were collected when the module was installed. + $this->container->get(StatusChecker::class)->clearStoredResults(); + + // Flag a validation error, whose summary will be displayed in the messages + // area. + $result = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + + $this->drupalGet('/admin/reports/status'); + $this->assertErrors([$result], TRUE); + + // Clear the result, and ensure that it's still visible because it is + // cached. + TestSubscriber::setTestResult(NULL, StatusCheckEvent::class); + $session->reload(); + $this->assertErrors([$result], TRUE); + + // If unattended updates are configured to run via the command line, we + // should see a warning that the status checks have not run recently. This + // is because changing the configuration clears the cached results, since + // they may be affected by the change. + // @see \Drupal\auto_updates\Validation\StatusChecker::onConfigSave() + $this->config('auto_updates.settings') + ->set('unattended.method', 'console') + ->save(); + $session->reload(); + $assert_session = $this->assertSession(); + $assert_session->pageTextContainsOnce('Unattended updates are configured to run via the console, but do not appear to have run recently.'); + $assert_session->pageTextNotContains((string) $result->messages[0]->render()); + } + + /** + * Tests the status checks when unattended updates are run via the console. + */ + public function testUnattendedUpdatesRunFromConsole(): void { + $this->container->get('module_installer')->install(['auto_updates']); + $this->container = $this->container->get('kernel')->getContainer(); + + // Clear stored results that were collected when the module was installed. + $this->container->get(StatusChecker::class)->clearStoredResults(); + + $this->config('auto_updates.settings') + ->set('unattended.method', 'console') + ->save(); + + // If we visit the status report, we should see an error requirement because + // unattended updates are configured to run via the terminal, and there are + // no stored status check results, which means that the console command has + // probably not run recently (or ever). + $this->drupalGet('/admin/reports/status'); + $this->assertRequirement('error', 'Unattended updates are configured to run via the console, but do not appear to have run recently.', [], FALSE); + + // We should see a similar message on any other admin page. + $this->drupalGet('/admin/structure'); + $this->assertSession() + ->statusMessageContains('Unattended updates are configured to run via the console, but not appear to have run recently.', 'error'); + } + + /** + * Asserts that the status check requirement displays no errors or warnings. + * + * @param bool $run_link + * (optional) Whether there should be a link to run the status checks. + * Defaults to FALSE. + */ + private function assertNoErrors(bool $run_link = FALSE): void { + $this->assertRequirement('checked', 'Your site is ready for automatic updates.', [], $run_link); + } + + /** + * Asserts that the displayed status check requirement contains warnings. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The status check results that should be visible. + * @param bool $run_link + * (optional) Whether there should be a link to run the status checks. + * Defaults to FALSE. + */ + private function assertWarnings(array $expected_results, bool $run_link = FALSE): void { + $this->assertRequirement('warning', static::$warningsExplanation, $expected_results, $run_link); + } + + /** + * Asserts that the displayed status check requirement contains errors. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The status check results that should be visible. + * @param bool $run_link + * (optional) Whether there should be a link to run the status checks. + * Defaults to FALSE. + */ + private function assertErrors(array $expected_results, bool $run_link = FALSE): void { + $this->assertRequirement('error', static::$errorsExplanation, $expected_results, $run_link); + } + + /** + * Asserts that the status check requirement is correct. + * + * @param string $section + * The section of the status report in which the requirement is expected to + * be. Can be one of 'error', 'warning', 'checked', or 'ok'. + * @param string $preamble + * The text that should appear before the result messages. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected status check results, in the order we expect them to be + * displayed. + * @param bool $run_link + * (optional) Whether there should be a link to run the status checks. + * Defaults to FALSE. + * + * @see \Drupal\Core\Render\Element\StatusReport::getInfo() + */ + private function assertRequirement(string $section, string $preamble, array $expected_results, bool $run_link = FALSE): void { + // Get the meaty part of the requirement element, and ensure that it begins + // with the preamble, if any. + $requirement = $this->assertSession() + ->elementExists('css', "h3#$section ~ details.system-status-report__entry:contains('Update readiness checks') .system-status-report__entry__value"); + + if ($preamble) { + $this->assertStringStartsWith($preamble, $requirement->getText()); + } + + // Convert the expected results into strings. + $expected_messages = []; + foreach ($expected_results as $result) { + $messages = $result->messages; + $summary = $result->summary; + if ($summary) { + $expected_messages[] = $summary; + } + $expected_messages = array_merge($expected_messages, $messages); + } + $expected_messages = array_map('strval', $expected_messages); + + // The results should appear in the given order. + $this->assertSame($expected_messages, $this->getMessagesFromRequirement($requirement)); + // Check for the presence or absence of a link to run the checks. + $this->assertSame($run_link, $requirement->hasLink('Rerun readiness checks')); + } + + /** + * Extracts the status check result messages from the requirement element. + * + * @param \Behat\Mink\Element\NodeElement $requirement + * The page element containing the status check results. + * + * @return string[] + * The status result messages (including summaries), in the order they + * appear on the page. + */ + private function getMessagesFromRequirement(NodeElement $requirement): array { + $messages = []; + + // Each list item will either contain a simple string (for results with only + // one message), or a details element with a series of messages. + $items = $requirement->findAll('css', 'li'); + foreach ($items as $item) { + $details = $item->find('css', 'details'); + + if ($details) { + $messages[] = $details->find('css', 'summary')->getText(); + $messages = array_merge($messages, $this->getMessagesFromRequirement($details)); + } + else { + $messages[] = $item->getText(); + } + } + return array_unique($messages); + } + + /** + * Runs status checks. + */ + private function runStatusChecks(): void { + $this->drupalGet('/admin/reports/status'); + $this->clickLink('Rerun readiness checks'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/StatusCheckerRunAfterUpdateTest.php b/core/modules/auto_updates/tests/src/Functional/StatusCheckerRunAfterUpdateTest.php new file mode 100644 index 000000000000..24deb7b82147 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/StatusCheckerRunAfterUpdateTest.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class StatusCheckerRunAfterUpdateTest extends UpdaterFormTestBase { + + /** + * Data provider for testStatusCheckerRunAfterUpdate(). + * + * @return bool[][] + * The test cases. + */ + public function providerStatusCheckerRunAfterUpdate(): array { + return [ + 'has database updates' => [TRUE], + 'does not have database updates' => [FALSE], + ]; + } + + /** + * Tests status checks are run after an update. + * + * @param bool $has_database_updates + * Whether the site has database updates or not. + * + * @dataProvider providerStatusCheckerRunAfterUpdate + */ + public function testStatusCheckerRunAfterUpdate(bool $has_database_updates) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $page = $this->getSession()->getPage(); + // Navigate to the automatic updates form. + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + $this->assertUpdateReady('9.8.1'); + // Set an error before completing the update. This error should be visible + // on admin pages after completing the update without having to explicitly + // run the status checks. + TestSubscriber1::setTestResult([ValidationResult::createError([t('Error before continue.')])], StatusCheckEvent::class); + if ($has_database_updates) { + // Simulate a staged database update in the auto_updates_test module. + // We must do this after the update has started, because the pending + // updates validator will prevent an update from starting. + $this->container->get('state') + ->set('auto_updates_test.new_update', TRUE); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContainsOnce('An error has occurred.'); + $assert_session->pageTextContainsOnce('Continue to the error page'); + $page->clickLink('the error page'); + $assert_session->pageTextContains('Some modules have database schema updates to install. You should run the database update script immediately.'); + $assert_session->linkExists('database update script'); + $assert_session->linkByHrefExists('/update.php'); + $page->clickLink('database update script'); + $this->assertSession()->addressEquals('/update.php'); + $assert_session->pageTextNotContains('Possible database updates have been detected in the following extension'); + $page->clickLink('Continue'); + // @see auto_updates_update_1191934() + $assert_session->pageTextContains('Dynamic auto_updates_update_1191934'); + $page->clickLink('Apply pending updates'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('Updates were attempted.'); + } + else { + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->addressEquals('/admin/reports/updates'); + $assert_session->pageTextContainsOnce('Update complete!'); + } + // Status checks should display errors on admin page. + $this->drupalGet('/admin'); + // Confirm that the status checks were run and the new error is displayed. + $assert_session->statusMessageContains('Error before continue.', 'error'); + $assert_session->statusMessageContains('Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.', 'error'); + $assert_session->pageTextNotContains('Your site has not recently run an update readiness check. Rerun readiness checks now.'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/SuccessfulUpdateTest.php b/core/modules/auto_updates/tests/src/Functional/SuccessfulUpdateTest.php new file mode 100644 index 000000000000..b5fc95beb6ac --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/SuccessfulUpdateTest.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\package_manager\Event\PreApplyEvent; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class SuccessfulUpdateTest extends UpdaterFormTestBase { + + /** + * Data provider for testSuccessfulUpdate(). + * + * @return string[][] + * The test cases. + */ + public function providerSuccessfulUpdate(): array { + return [ + 'Modules page, maintenance mode on' => [ + '/admin/modules/update', + TRUE, + ], + 'Modules page, maintenance mode off' => [ + '/admin/modules/update', + FALSE, + ], + 'Reports page, maintenance mode on' => [ + '/admin/reports/updates/update', + TRUE, + ], + 'Reports page, maintenance mode off' => [ + '/admin/reports/updates/update', + FALSE, + ], + ]; + } + + /** + * Tests an update that has no errors or special conditions. + * + * @param string $update_form_url + * The URL of the update form to visit. + * @param bool $maintenance_mode_on + * Whether maintenance should be on at the beginning of the update. + * + * @dataProvider providerSuccessfulUpdate + */ + public function testSuccessfulUpdate(string $update_form_url, bool $maintenance_mode_on): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $state = $this->container->get('state'); + $state->set('system.maintenance_mode', $maintenance_mode_on); + $page = $this->getSession()->getPage(); + $cached_message = $this->setAndAssertCachedMessage(); + + $this->drupalGet($update_form_url); + $assert_session->pageTextNotContains($cached_message->render()); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + $this->assertUpdateReady('9.8.1'); + // Confirm that the site was put into maintenance mode if needed. + $this->assertMaintenanceMode($maintenance_mode_on); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->addressEquals('/admin/reports/updates'); + $assert_session->pageTextNotContains($cached_message->render()); + // Confirm that the site was in maintenance before the update was applied. + // @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent() + $this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode')); + $assert_session->pageTextContainsOnce('Update complete!'); + // Confirm the site was returned to the original maintenance mode state. + $this->assertMaintenanceMode($maintenance_mode_on); + // Confirm that the apply and post-apply operations happened in + // separate requests. + // @see \Drupal\auto_updates_test\EventSubscriber\RequestTimeRecorder + $pre_apply_time = $state->get('Drupal\package_manager\Event\PreApplyEvent time'); + $post_apply_time = $state->get('Drupal\package_manager\Event\PostApplyEvent time'); + $this->assertNotEmpty($pre_apply_time); + $this->assertNotEmpty($post_apply_time); + $this->assertNotSame($pre_apply_time, $post_apply_time); + } + + /** + * Asserts maintenance is the expected value and correct message appears. + * + * @param bool $expected_maintenance_mode + * Whether maintenance mode is expected to be on or off. + */ + private function assertMaintenanceMode(bool $expected_maintenance_mode): void { + $this->assertSame($this->container->get('state') + ->get('system.maintenance_mode'), $expected_maintenance_mode); + if ($expected_maintenance_mode) { + $this->assertSession() + ->pageTextContains('Operating in maintenance mode.'); + } + else { + $this->assertSession() + ->pageTextNotContains('Operating in maintenance mode.'); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/TableLooksCorrectTest.php b/core/modules/auto_updates/tests/src/Functional/TableLooksCorrectTest.php new file mode 100644 index 000000000000..222dc5172328 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/TableLooksCorrectTest.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class TableLooksCorrectTest extends UpdaterFormTestBase { + + /** + * Data provider for testTableLooksCorrect(). + * + * @return string[][] + * The test cases. + */ + public function providerTableLooksCorrect(): array { + return [ + 'Modules page' => ['modules'], + 'Reports page' => ['reports'], + ]; + } + + /** + * Tests that available updates are rendered correctly in a table. + * + * @param string $access_page + * The page from which the update form should be visited. + * Can be one of 'modules' to visit via the module list, or 'reports' to + * visit via the administrative reports page. + * + * @dataProvider providerTableLooksCorrect + */ + public function testTableLooksCorrect(string $access_page): void { + $assert_session = $this->assertSession(); + + $assert_minor_update_help = function () use ($assert_session): void { + $assert_session->pageTextContainsOnce('The following updates are in newer minor version of Drupal. Learn more about updating to another minor version.'); + $assert_session->linkExists('Learn more about updating to another minor version.'); + }; + $assert_no_minor_update_help = function () use ($assert_session): void { + $assert_session->pageTextNotContains('The following updates are in newer minor version of Drupal. Learn more about updating to another minor version.'); + }; + + $page = $this->getSession()->getPage(); + $this->drupalPlaceBlock('local_tasks_block', ['primary' => TRUE]); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + + // Navigate to the automatic updates form. + $this->drupalGet('/admin'); + if ($access_page === 'modules') { + $this->clickLink('Extend'); + $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.'); + } + else { + $this->clickLink('Reports'); + $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.'); + $this->clickLink('Available updates'); + } + $this->clickLink('Update'); + + // Check the form when there is an update in the installed minor only. + $assert_session->pageTextContainsOnce('Currently installed: 9.8.0 (Security update required!)'); + $this->checkReleaseTable('#edit-installed-minor', '.update-update-security', '9.8.1', TRUE, 'Latest version of Drupal 9.8 (currently installed):'); + $assert_session->elementNotExists('css', '#edit-next-minor-1'); + $assert_no_minor_update_help(); + + // Check the form when there is an update in the next minor only. + $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save(); + $this->mockActiveCoreVersion('9.7.0'); + $page->clickLink('Check manually'); + $this->checkForMetaRefresh(); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-recommended', '9.8.1', TRUE, 'Latest version of Drupal 9.8 (next minor) (Release notes):'); + $assert_minor_update_help(); + $this->assertReleaseNotesLink(9, 8, '#edit-next-minor-1'); + $assert_session->pageTextContainsOnce('Currently installed: 9.7.0 (Not supported!)'); + $assert_session->elementNotExists('css', '#edit-installed-minor'); + + // Check the form when there are updates in the current and next minors but + // the site does not support minor updates. + $this->config('auto_updates.settings')->set('allow_core_minor_updates', FALSE)->save(); + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml'); + $page->clickLink('Check manually'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContainsOnce('Currently installed: 9.7.0 (Update available)'); + $this->checkReleaseTable('#edit-installed-minor', '.update-update-recommended', '9.7.1', TRUE, 'Latest version of Drupal 9.7 (currently installed):'); + $assert_session->elementNotExists('css', '#edit-next-minor-1'); + $assert_no_minor_update_help(); + + // Check that if minor updates are enabled the update in the next minor will + // be visible. + $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save(); + $this->getSession()->reload(); + $this->checkReleaseTable('#edit-installed-minor', '.update-update-recommended', '9.7.1', TRUE, 'Latest version of Drupal 9.7 (currently installed):'); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-optional', '9.8.2', FALSE, 'Latest version of Drupal 9.8 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 8, '#edit-next-minor-1'); + $assert_minor_update_help(); + + $this->mockActiveCoreVersion('9.7.1'); + $page->clickLink('Check manually'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContainsOnce('Currently installed: 9.7.1 (Update available)'); + $assert_session->elementNotExists('css', '#edit-installed-minor'); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-recommended', '9.8.2', FALSE, 'Latest version of Drupal 9.8 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 8, '#edit-next-minor-1'); + $assert_minor_update_help(); + + // Check that if minor updates are enabled then updates in the next minors + // are visible. + $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save(); + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.10.0.0.xml'); + $this->mockActiveCoreVersion('9.5.0'); + $page->clickLink('Check manually'); + $this->checkForMetaRefresh(); + $assert_session->pageTextNotContains('10.0.0'); + $assert_session->pageTextContainsOnce('Currently installed: 9.5.0 (Update available)'); + $this->checkReleaseTable('#edit-installed-minor', '.update-update-recommended', '9.5.1', TRUE, 'Latest version of Drupal 9.5 (currently installed):'); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-optional', '9.6.1', FALSE, 'Latest version of Drupal 9.6 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 6, '#edit-next-minor-1'); + $this->checkReleaseTable('#edit-next-minor-2', '.update-update-optional', '9.7.2', FALSE, 'Latest version of Drupal 9.7 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 7, '#edit-next-minor-2'); + $assert_minor_update_help(); + + // Check that if installed version is unsupported and minor updates are + // enabled then updates in the next minors are visible. + $this->mockActiveCoreVersion('9.4.0'); + $page->clickLink('Check manually'); + $this->checkForMetaRefresh(); + $assert_session->pageTextNotContains('10.0.0'); + $assert_session->pageTextContainsOnce('Currently installed: 9.4.0 (Not supported!)'); + $this->checkReleaseTable('#edit-next-minor-1', '.update-update-recommended', '9.5.1', TRUE, 'Latest version of Drupal 9.5 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 5, '#edit-next-minor-1'); + $this->checkReleaseTable('#edit-next-minor-2', '.update-update-recommended', '9.6.1', FALSE, 'Latest version of Drupal 9.6 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 6, '#edit-next-minor-2'); + $this->checkReleaseTable('#edit-next-minor-3', '.update-update-recommended', '9.7.2', FALSE, 'Latest version of Drupal 9.7 (next minor) (Release notes):'); + $this->assertReleaseNotesLink(9, 7, '#edit-next-minor-3'); + $assert_minor_update_help(); + + $this->assertUpdateStagedTimes(0); + + // If the minor update help link exists, ensure it links to the right place. + $help_link = $page->findLink('Learn more about updating to another minor version.'); + if ($help_link) { + $this->assertStringEndsWith('#minor-update', $help_link->getAttribute('href')); + $help_link->click(); + $assert_session->statusCodeEquals(200); + $assert_session->responseContains('id="minor-update"'); + } + } + + /** + * Asserts that the release notes link for a given minor version is correct. + * + * @param int $major + * Major version of next minor release. + * @param int $minor + * Minor version of next minor release. + * @param string $selector + * The selector. + */ + private function assertReleaseNotesLink(int $major, int $minor, string $selector): void { + $assert_session = $this->assertSession(); + $row = $assert_session->elementExists('css', $selector); + $link_href = $assert_session->elementExists('named', ['link', 'Release notes'], $row)->getAttribute('href'); + $this->assertSame('http://example.com/drupal-' . $major . '-' . $minor . '-0-release', $link_href); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateCompleteMessageTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateCompleteMessageTest.php new file mode 100644 index 000000000000..885ec83010c8 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateCompleteMessageTest.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager\Event\PostApplyEvent; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class UpdateCompleteMessageTest extends UpdaterFormTestBase { + + /** + * Data provider for testUpdateCompleteMessage(). + * + * @return string[][] + * The test cases. + */ + public function providerUpdateCompleteMessage(): array { + return [ + 'maintenance mode off' => [FALSE], + 'maintenance mode on' => [TRUE], + ]; + } + + /** + * Tests the update complete message is displayed when another message exist. + * + * @param bool $maintenance_mode_on + * Whether maintenance should be on at the beginning of the update. + * + * @dataProvider providerUpdateCompleteMessage + */ + public function testUpdateCompleteMessage(bool $maintenance_mode_on): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $state = $this->container->get('state'); + $state->set('system.maintenance_mode', $maintenance_mode_on); + $page = $this->getSession()->getPage(); + + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + // Confirm that the site was put into maintenance mode if needed. + $custom_message = 'custom status message.'; + TestSubscriber1::setMessage($custom_message, PostApplyEvent::class); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContainsOnce($custom_message); + $assert_session->pageTextContainsOnce('Update complete!'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateErrorTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateErrorTest.php new file mode 100644 index 000000000000..d119ba8930f9 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateErrorTest.php @@ -0,0 +1,281 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +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\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\system\SystemManager; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + * + * @todo Consolidate and remove duplicate test coverage in + * https://drupal.org/i/3354325. + */ +class UpdateErrorTest extends UpdaterFormTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->config('system.logging') + ->set('error_level', ERROR_REPORTING_DISPLAY_VERBOSE) + ->save(); + } + + /** + * Tests that update cannot be completed via the UI if a status check fails. + */ + public function testStatusCheckErrorPreventsUpdate(): void { + $session = $this->getSession(); + $assert_session = $this->assertSession(); + $page = $session->getPage(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + + $error_messages = [ + t("The only thing we're allowed to do is to"), + t("believe that we won't regret the choice"), + t("we made."), + ]; + $summary = t('some generic summary'); + $error = ValidationResult::createError($error_messages, $summary); + TestSubscriber::setTestResult([$error], StatusCheckEvent::class); + $this->getSession()->reload(); + $this->assertStatusMessageContainsResult($error); + $assert_session->buttonNotExists('Continue'); + $assert_session->buttonExists('Cancel update'); + + // An error with only one message should also show the summary. + $error = ValidationResult::createError([t('Yet another smarmy error.')], $summary); + TestSubscriber::setTestResult([$error], StatusCheckEvent::class); + $this->getSession()->reload(); + $this->assertStatusMessageContainsResult($error); + $assert_session->buttonNotExists('Continue'); + $assert_session->buttonExists('Cancel update'); + } + + /** + * Tests that throwables will be displayed properly. + */ + public function testDisplayErrorCreatedFromThrowable(): void { + $throwable = new \Exception("I want to be the pirate king because he's the freest man alive."); + $result = ValidationResult::createErrorFromThrowable($throwable); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + $this->drupalGet('/admin/reports/status'); + $this->clickLink('Rerun readiness checks'); + $this->drupalGet('/admin'); + $assert_session = $this->assertSession(); + $assert_session->statusCodeEquals(200); + $assert_session->statusMessageContains($throwable->getMessage(), 'error'); + } + + /** + * Tests the display of errors and warnings during status check. + */ + public function testStatusCheckErrorDisplay(): void { + $session = $this->getSession(); + $assert_session = $this->assertSession(); + + $cached_message = $this->setAndAssertCachedMessage(); + // Ensure that the fake error is cached. + $session->reload(); + $assert_session->pageTextContainsOnce((string) $cached_message); + + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + + // Set up a new fake error. Use an error with multiple messages so we can + // ensure that they're all displayed, along with their summary. + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + + // If a validator raises an error during status checking, the form should + // not have a submit button. + $this->drupalGet('/admin/modules/update'); + $this->assertNoUpdateButtons(); + // Since this is an administrative page, the error message should be visible + // thanks to auto_updates_page_top(). The status checks were re-run + // during the form build, which means the new error should be cached and + // displayed instead of the previously cached error. + $this->assertStatusMessageContainsResult($expected_results[0]); + $assert_session->pageTextContainsOnce(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $assert_session->pageTextNotContains($cached_message->render()); + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + + // Set up an error with one message and a summary. We should see both when + // we refresh the form. + $expected_result = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 1); + TestSubscriber1::setTestResult([$expected_result], StatusCheckEvent::class); + $this->getSession()->reload(); + $this->assertNoUpdateButtons(); + $this->assertStatusMessageContainsResult($expected_result); + $assert_session->pageTextContainsOnce(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $assert_session->pageTextNotContains($cached_message->render()); + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + } + + /** + * Tests handling of exceptions and errors raised by event subscribers. + * + * @param string $event + * The event that should cause a problem. + * @param string $stopped_by + * Either 'exception' to throw an exception on the given event, or + * 'validation error' to flag a validation error instead. + * + * @dataProvider providerUpdateStoppedByEventSubscriber + */ + public function testUpdateStoppedByEventSubscriber(string $event, string $stopped_by): void { + $expected_message = 'Bad news bears!'; + + if ($stopped_by === 'validation error') { + $result = ValidationResult::createError([ + // @codingStandardsIgnoreLine + t($expected_message), + ]); + TestSubscriber::setTestResult([$result], $event); + } + else { + $this->assertSame('exception', $stopped_by); + TestSubscriber::setException(new \Exception($expected_message), $event); + } + + // Only simulate a staged update if we're going to get far enough that the + // stage directory will be created. + if ($event !== StatusCheckEvent::class && $event !== PreCreateEvent::class) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + } + + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + + // StatusCheckEvent runs very early, before we can even start the update. + // If it raises the error we're expecting, we're done. + if ($event === StatusCheckEvent::class) { + // If we are flagging a validation error, we should see an explanatory + // message. If we're throwing an exception, we shouldn't. + if ($stopped_by === 'validation error') { + $assert_session->statusMessageContains(static::$errorsExplanation, 'error'); + } + else { + $assert_session->pageTextNotContains(static::$errorsExplanation); + } + $assert_session->pageTextNotContains(static::$warningsExplanation); + $assert_session->statusMessageContains($expected_message, 'error'); + // We shouldn't be able to start the update. + $assert_session->buttonNotExists('Update to 9.8.1'); + return; + } + + // Start the update. + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + // If the batch job fails, proceed to the error page. If it failed because + // of the exception we set up, we're done. + if ($page->hasLink('the error page')) { + // We should see the exception's backtrace. + $assert_session->responseContains('<pre class="backtrace">'); + $page->clickLink('the error page'); + $assert_session->statusMessageContains($expected_message, 'error'); + // We should be on the start page. + $assert_session->addressEquals('/admin/modules/update'); + + // If we failed during post-create, the stage is not destroyed, so we + // should not be able to start the update anew without destroying the + // stage first. In all other cases, the stage should have been destroyed + // (or never created at all) and we should be able to try again. + // @todo Delete the existing update on behalf of the user in + // https://drupal.org/i/3346644. + if ($event === PostCreateEvent::class) { + $assert_session->pageTextContains('Cannot begin an update because another Composer operation is currently in progress.'); + $assert_session->buttonNotExists('Update to 9.8.1'); + $assert_session->buttonExists('Delete existing update'); + } + else { + $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.'); + $assert_session->buttonExists('Update to 9.8.1'); + $assert_session->buttonNotExists('Delete existing update'); + } + return; + } + + // We should now be ready to finish the update. + $this->assertStringContainsString('/admin/automatic-update-ready/', $session->getCurrentUrl()); + // Ensure that we are expecting a failure from an event that is dispatched + // during the second phase (apply and destroy) of the update. + $this->assertContains($event, [ + PreApplyEvent::class, + PostApplyEvent::class, + ]); + // Try to finish the update. + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + // As we did before, proceed to the error page if the batch job fails. If it + // failed because of the exception we set up, we're done here. + if ($page->hasLink('the error page')) { + // We should see the exception's backtrace. + $assert_session->responseContains('<pre class="backtrace">'); + $page->clickLink('the error page'); + // We should be back on the "ready to update" page, and the exception + // message should be visible. + $this->assertStringContainsString('/admin/automatic-update-ready/', $session->getCurrentUrl()); + $assert_session->statusMessageContains($expected_message, 'error'); + } + } + + /** + * Data provider for ::testUpdateStoppedByEventSubscriber(). + * + * @return array[] + * The test cases. + */ + public function providerUpdateStoppedByEventSubscriber(): array { + $events = [ + StatusCheckEvent::class, + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + ]; + $data = []; + foreach ($events as $event) { + $data["exception from $event"] = [$event, 'exception']; + + // Only the pre-operation events support flagging validation errors. + if (is_subclass_of($event, PreOperationStageEvent::class)) { + $data["validation error from $event"] = [$event, 'validation error']; + } + } + return $data; + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateFailedTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateFailedTest.php new file mode 100644 index 000000000000..932d6c2a73cf --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateFailedTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\package_manager_bypass\LoggingCommitter; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class UpdateFailedTest extends UpdaterFormTestBase { + + /** + * Tests that an exception is thrown if a previous apply failed. + */ + public function testMarkerFileFailure(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $session = $this->getSession(); + $assert_session = $this->assertSession(); + $page = $session->getPage(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $this->drupalGet('/admin/modules/update'); + $assert_session->pageTextNotContains(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + + LoggingCommitter::setException(new \Exception('failed at committer')); + $page->pressButton('Continue'); + $this->checkForMetaRefresh(); + $failure_message = 'Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.'; + $assert_session->pageTextContainsOnce('An error has occurred.'); + $assert_session->pageTextContains($failure_message); + $page->clickLink('the error page'); + + // We should be on the form (i.e., 200 response code), but unable to + // continue the update. + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains($failure_message); + $assert_session->buttonNotExists('Continue'); + // The same thing should be true if we try to start from the beginning. + $this->drupalGet('/admin/modules/update'); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains($failure_message); + $assert_session->buttonNotExists('Update'); + } + + /** + * Tests what happens when a staged update is deleted without being destroyed. + */ + public function testStagedUpdateDeletedImproperly(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $page = $this->getSession()->getPage(); + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + $this->assertUpdateReady('9.8.1'); + // Confirm if the staged directory is deleted without using destroy(), then + // an error message will be displayed on the page. + // @see \Drupal\package_manager\Stage::getStagingRoot() + /** @var \Drupal\Core\File\FileSystemInterface $file_system */ + $file_system = $this->container->get('file_system'); + $dir = $file_system->getTempDirectory() . '/.package_manager' . $this->config('system.site')->get('uuid'); + $this->assertDirectoryExists($dir); + $file_system->deleteRecursive($dir); + $this->getSession()->reload(); + $assert_session = $this->assertSession(); + $error_message = 'There was an error loading the pending update. Press the Cancel update button to start over.'; + $assert_session->pageTextContainsOnce($error_message); + // We should be able to start over without any problems, and the error + // message should not be seen on the updater form. + $page->pressButton('Cancel update'); + $assert_session->addressEquals('/admin/reports/updates/update'); + $assert_session->pageTextNotContains($error_message); + $assert_session->pageTextContains('The update was successfully cancelled.'); + $assert_session->buttonExists('Update'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php new file mode 100644 index 000000000000..98833c2a0bff --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +/** + * Tests that only one Automatic Update operation can be performed at a time. + * + * @group auto_updates + * @internal + */ +class UpdateLockTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $user = $this->createUser([ + 'administer site configuration', + ]); + $this->drupalLogin($user); + $this->checkForUpdates(); + } + + /** + * Tests that only user who started an update can continue through it. + */ + public function testLock(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2'); + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $permissions = ['administer software updates']; + $user_1 = $this->createUser($permissions); + $user_2 = $this->createUser($permissions); + + // We should be able to get partway through an update without issue. + $this->drupalLogin($user_1); + $this->drupalGet('/admin/modules/update'); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady('9.8.2'); + $assert_session->buttonExists('Continue'); + $url = $this->getSession()->getCurrentUrl(); + + // Another user cannot show up and try to start an update, since the other + // user already started one. + $this->drupalLogin($user_2); + $this->drupalGet('/admin/modules/update'); + $assert_session->buttonNotExists('Update'); + $assert_session->pageTextContains('Cannot begin an update because another Composer operation is currently in progress.'); + + // If the current user did not start the update, they should not be able to + // continue it, either. + $this->drupalGet($url); + $assert_session->pageTextContains('Cannot claim the stage because it is not owned by the current user or session.'); + $assert_session->buttonNotExists('Continue'); + + // The user who started the update should be able to continue it. + $this->drupalLogin($user_1); + $this->drupalGet($url); + $assert_session->pageTextNotContains('Cannot continue the update because another Composer operation is currently in progress.'); + $assert_session->buttonExists('Continue'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateWarningTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateWarningTest.php new file mode 100644 index 000000000000..05b8276ae78f --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateWarningTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * @group auto_updates + * @internal + */ +class UpdateWarningTest extends UpdaterFormTestBase { + + /** + * Tests that update can be completed even if a status check throws a warning. + */ + public function testContinueOnWarning(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $session = $this->getSession(); + + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->drupalGet('/admin/modules/update'); + $session->getPage()->pressButton('Update to 9.8.1'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + + $messages = [ + t("The only thing we're allowed to do is to"), + t("believe that we won't regret the choice"), + t("we made."), + ]; + $summary = t('some generic summary'); + $warning = ValidationResult::createWarning($messages, $summary); + TestSubscriber::setTestResult([$warning], StatusCheckEvent::class); + $session->reload(); + + $assert_session = $this->assertSession(); + $assert_session->buttonExists('Continue'); + $this->assertStatusMessageContainsResult($warning); + + // A warning with only one message should also show its summary. + $warning = ValidationResult::createWarning([t("I'm still warning you.")], $summary); + TestSubscriber::setTestResult([$warning], StatusCheckEvent::class); + $session->reload(); + $this->assertStatusMessageContainsResult($warning); + $assert_session->buttonExists('Continue'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php new file mode 100644 index 000000000000..5f3c3639c99d --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +/** + * Tests messages on the updater form when there is no recommended release. + * + * @group auto_updates + * @internal + */ +class UpdaterFormNoRecommendedReleaseMessageTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $account = $this->drupalCreateUser([ + 'administer software updates', + 'administer site configuration', + ]); + $this->drupalLogin($account); + } + + /** + * Data provider for testMessages(). + * + * @return mixed[][] + * The test cases. + */ + public function providerMessages(): array { + $dir = __DIR__ . '/../../../../package_manager/tests/fixtures/release-history'; + + return [ + 'current' => [ + $dir . '/drupal.9.8.1-security.xml', + '9.8.1', + FALSE, + 'status', + ], + 'not current' => [ + $dir . '/drupal.9.8.2.xml', + '9.7.1', + TRUE, + 'status', + ], + 'insecure' => [ + $dir . '/drupal.9.8.1-security.xml', + '9.7.1', + TRUE, + 'error', + ], + ]; + } + + /** + * Tests messages when there is no recommended release. + * + * @param string $release_metadata + * The path of the release metadata to use. + * @param string $installed_version + * The currently installed version of Drupal core. + * @param bool $updates_available + * Whether or not any available updates will be detected. + * @param string $expected_message_type + * The expected type of message (status or error). + * + * @dataProvider providerMessages + */ + public function testMessages(string $release_metadata, string $installed_version, bool $updates_available, string $expected_message_type): void { + $this->setReleaseMetadata($release_metadata); + $this->mockActiveCoreVersion($installed_version); + $this->checkForUpdates(); + $this->drupalGet('/admin/reports/updates/update'); + + $assert_session = $this->assertSession(); + if ($updates_available) { + $assert_session->statusMessageContains('Updates were found, but they must be performed manually.', $expected_message_type); + $assert_session->linkExists('the list of available updates'); + } + else { + $assert_session->statusMessageContains('No update available', $expected_message_type); + } + $assert_session->buttonNotExists('Update'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTestBase.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTestBase.php new file mode 100644 index 000000000000..ab8faea7dade --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTestBase.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; + +/** + * Base class for functional tests of updater form. + * + * @internal + */ +abstract class UpdaterFormTestBase extends AutoUpdatesFunctionalTestBase { + + use PackageManagerBypassTestTrait; + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'auto_updates', + 'auto_updates_test', + 'help', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + static::$errorsExplanation = 'Your site cannot be automatically updated until further action is performed.'; + parent::setUp(); + + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml'); + $permissions = [ + 'administer site configuration', + 'administer software updates', + 'access administration pages', + 'access site in maintenance mode', + 'administer modules', + 'access site reports', + 'view update notifications', + 'access help pages', + ]; + $user = $this->createUser($permissions); + $this->drupalLogin($user); + $this->checkForUpdates(); + } + + /** + * Asserts that no update buttons exist. + */ + protected function assertNoUpdateButtons(): void { + $this->assertSession()->elementNotExists('css', "input[value*='Update']"); + } + + /** + * Sets an error message, runs status checks, and asserts it is displayed. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The cached error check message. + */ + protected function setAndAssertCachedMessage(): TranslatableMarkup { + // Store a status error, which will be cached. + $message = t("You've not experienced Shakespeare until you have read him in the original Klingon."); + $result = ValidationResult::createError([$message]); + TestSubscriber1::setTestResult([$result], StatusCheckEvent::class); + // Run the status checks a visit an admin page the message will be + // displayed. + $this->drupalGet('/admin/reports/status'); + $this->clickLink('Rerun readiness checks'); + $this->drupalGet('/admin'); + $this->assertSession()->pageTextContains($message->render()); + // Clear the results so the only way the message could appear on the pages + // used for the update process is if they show the cached results. + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + + return $message; + } + + /** + * Checks the table for a release on the form. + * + * @param string $container_locator + * The CSS locator for the element with contains the table. + * @param string $row_class + * The row class for the update. + * @param string $version + * The release version number. + * @param bool $is_primary + * Whether update button should be a primary button. + * @param string|null $table_caption + * The table caption or NULL if none expected. + */ + protected function checkReleaseTable(string $container_locator, string $row_class, string $version, bool $is_primary, ?string $table_caption = NULL): void { + $assert_session = $this->assertSession(); + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + $assert_session->linkExists('Drupal core'); + $container = $assert_session->elementExists('css', $container_locator); + if ($table_caption) { + $this->assertSame($table_caption, $assert_session->elementExists('css', 'caption', $container)->getText()); + } + else { + $assert_session->elementNotExists('css', 'caption', $container); + } + + $cells = $assert_session->elementExists('css', $row_class, $container) + ->findAll('css', 'td'); + $this->assertCount(2, $cells); + $this->assertSame("$version (Release notes)", $cells[1]->getText()); + $release_notes = $assert_session->elementExists('named', ['link', 'Release notes'], $cells[1]); + $this->assertSame("Release notes for Drupal core $version", $release_notes->getAttribute('title')); + $button = $assert_session->buttonExists("Update to $version", $container); + $this->assertSame($is_primary, $button->hasClass('button--primary')); + } + + /** + * Asserts that a status message containing a given validation result exists. + * + * @param \Drupal\package_manager\ValidationResult $result + * A validation result. + */ + protected function assertStatusMessageContainsResult(ValidationResult $result): void { + $assert_session = $this->assertSession(); + $type = $result->severity === SystemManager::REQUIREMENT_ERROR ? 'error' : 'warning'; + $assert_session->statusMessageContains((string) $result->summary, $type); + $assert_session->pageTextContainsOnce((string) $result->summary); + foreach ($result->messages as $message) { + $assert_session->statusMessageContains((string) $message, $type); + $assert_session->pageTextContainsOnce((string) $message); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateErrorTest.php b/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateErrorTest.php new file mode 100644 index 000000000000..1df9be18a0c8 --- /dev/null +++ b/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateErrorTest.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\package_manager\Event\PreCreateEvent; + +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\Tests\auto_updates\Traits\TestSetUpTrait; + +/** + * Tests errors when JavaScript is enabled. + * + * @group auto_updates + */ +class UpdateErrorTest extends WebDriverTestBase { + + use TestSetUpTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'olivero'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'auto_updates', + 'auto_updates_test', + ]; + + /** + * {@inheritdoc} + */ + public function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml'); + $user = $this->createUser([ + 'administer site configuration', + 'administer software updates', + 'access administration pages', + 'access site in maintenance mode', + 'administer modules', + 'access site reports', + 'view update notifications', + ]); + $this->drupalLogin($user); + $this->mockActiveCoreVersion('9.8.0'); + $this->checkForUpdates(); + } + + /** + * Checks for available updates. + * + * Assumes that a user with appropriate permissions is logged in. + */ + protected function checkForUpdates(): void { + $this->drupalGet('/admin/reports/updates'); + $this->getSession()->getPage()->clickLink('Check manually'); + $this->checkForMetaRefresh(); + } + + /** + * Mocks 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 version of core to mock. + */ + protected function mockActiveCoreVersion(string $version): void { + $this->config('update_test.settings') + ->set('system_info.#all.version', $version) + ->save(); + } + + /** + * Tests that the update error page is displayed. + */ + public function testUpdateErrorPage(): void { + $error = ValidationResult::createError([t('Error during pre-create event')]); + TestSubscriber::setTestResult([$error], PreCreateEvent::class); + $page = $this->getSession()->getPage(); + $this->drupalGet('/admin/modules/update'); + $assert_session = $this->assertSession(); + $page->pressButton('Update to 9.8.1'); + $this->assertNotNull($assert_session->waitForLink('the error page', 100000)); + $assert_session->responseContains('Error during pre-create event'); + $this->clickLink('the error page'); + $assert_session->responseContains('Error during pre-create event'); + } + +} diff --git a/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateSettingsFormTest.php b/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateSettingsFormTest.php new file mode 100644 index 000000000000..6efd5aca4ceb --- /dev/null +++ b/core/modules/auto_updates/tests/src/FunctionalJavascript/UpdateSettingsFormTest.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\FunctionalJavascript; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\Tests\auto_updates\Traits\TestSetUpTrait; + +/** + * @group auto_updates + */ +class UpdateSettingsFormTest extends WebDriverTestBase { + + use TestSetUpTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * Tests Automatic Updates' alterations to the update settings form. + */ + public function testSettingsForm(): void { + $account = $this->drupalCreateUser(['administer site configuration']); + $this->drupalLogin($account); + $this->drupalGet('/admin/reports/updates/settings'); + + // The default values should be reflected. + $assert_session = $this->assertSession(); + $assert_session->fieldValueEquals('unattended_method', 'web'); + $assert_session->fieldValueEquals('unattended_level', CronUpdateRunner::DISABLED); + // Since unattended updates are disabled, the method radio buttons should be + // hidden. + $this->assertFalse($assert_session->fieldExists('unattended_method')->isVisible()); + + // Enabling unattended updates should reveal the method radio buttons. + $page = $this->getSession()->getPage(); + $page->selectFieldOption('unattended_level', CronUpdateRunner::SECURITY); + $this->assertNotEmpty($assert_session->waitForElementVisible('named', ['field', 'unattended_method'])); + $assert_session->elementAttributeContains('named', ['link', 'ensure cron is set up correctly'], 'href', 'http://drupal.org/docs/user_guide/en/security-cron.html'); + // Change the method, to ensure it is properly saved in config. + $page->selectFieldOption('unattended_method', 'console'); + + // Ensure the changes are reflected in config. + $page->pressButton('Save configuration'); + $config = $this->config('auto_updates.settings'); + $this->assertSame(CronUpdateRunner::SECURITY, $config->get('unattended.level')); + $this->assertSame('console', $config->get('unattended.method')); + // Our saved changes should be reflected in the form too. + $assert_session->fieldValueEquals('unattended_level', CronUpdateRunner::SECURITY); + $assert_session->fieldValueEquals('unattended_method', 'console'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php new file mode 100644 index 000000000000..4f4a43072ea9 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php @@ -0,0 +1,178 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\Validator\SymlinkValidator; +use Drupal\package_manager\Validator\WritableFileSystemValidator; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * Base class for kernel tests of the Automatic Updates module. + * + * @internal + */ +abstract class AutoUpdatesKernelTestBase extends PackageManagerKernelTestBase { + + use ValidationTestTrait; + + /** + * {@inheritdoc} + * + * TRICKY: due to the way that auto_updates forcibly disables cron-based + * updating for the end user, we need to override the current default + * configuration BEFORE the module is installed. This triggers config schema + * exceptions. Since none of these tests are interacting with configuration + * anyway, this is a reasonable temporary workaround. + * + * @see ::setUp() + * @see https://www.drupal.org/project/auto_updates/issues/3284443 + * @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + */ + protected $strictConfigSchema = FALSE; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // If Package Manager's file system permissions validator is disabled, also + // disable the Automatic Updates validator which wraps it. + if (in_array(WritableFileSystemValidator::class, $this->disableValidators, TRUE)) { + $this->disableValidators[] = 'auto_updates.validator.file_system_permissions'; + } + // If Package Manager's symlink validator is disabled, also disable the + // Automatic Updates validator which wraps it. + if (in_array(SymlinkValidator::class, $this->disableValidators, TRUE)) { + $this->disableValidators[] = 'auto_updates.validator.symlink'; + } + parent::setUp(); + // Enable cron updates, which will eventually be the default. + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings') + ->set('unattended', [ + 'method' => 'web', + 'level' => CronUpdateRunner::SECURITY, + ]) + ->save(); + + // By default, pretend we're running Drupal core 9.8.0 and a non-security + // update to 9.8.1 is available. + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml']); + + // Set a last cron run time so that the cron frequency validator will run + // from a sane state. + // @see \Drupal\auto_updates\Validator\CronFrequencyValidator + $this->container->get('state')->set('system.cron_last', time()); + + // Cron updates are not done when running at the command line, so override + // our cron handler's PHP_SAPI constant to a valid value that isn't `cli`. + // The choice of `cgi-fcgi` is arbitrary; see + // https://www.php.net/php_sapi_name for some valid values of PHP_SAPI. + $property = new \ReflectionProperty(CronUpdateRunner::class, 'serverApi'); + $property->setValue(NULL, 'cgi-fcgi'); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // Use the test-only implementations of the regular and cron update runner. + $overrides = [ + CronUpdateRunner::class => TestCronUpdateRunner::class, + ConsoleUpdateStage::class => TestConsoleUpdateStage::class, + ]; + foreach ($overrides as $service_id => $class) { + if ($container->hasDefinition($service_id)) { + $container->getDefinition($service_id)->setClass($class); + } + } + } + + /** + * Performs an update using the console update stage directly. + */ + protected function runConsoleUpdateStage(): void { + $this->container->get(ConsoleUpdateStage::class)->performUpdate(); + } + + /** + * Asserts that an exception containing a particular message was logged. + * + * @param string $message + * The message that should have been logged. + * @param \ColinODell\PsrTestLogger\TestLogger $logger + * The logger. + */ + protected function assertExceptionLogged(string $message, TestLogger $logger): void { + $predicate = fn ($record) => str_contains($record['context']['@message'] ?? '', $message); + $this->assertTrue($logger->hasRecordThatPasses($predicate, RfcLogLevel::ERROR)); + } + +} + +/** + * A test-only version of the cron update runner to override and expose internals. + */ +class TestCronUpdateRunner extends CronUpdateRunner { + + /** + * {@inheritdoc} + */ + protected function runTerminalUpdateCommand(): never { + // Invoking the terminal command will not work and is not necessary in + // kernel tests. Throw an exception for tests that need to assert that + // the terminal command would have been invoked. + throw new \BadMethodCallException(static::class); + } + +} + +/** + * A test version of the console update stage to override and expose internals. + */ +class TestConsoleUpdateStage extends ConsoleUpdateStage { + + /** + * {@inheritdoc} + */ + protected string $type = 'auto_updates:unattended'; + + /** + * {@inheritdoc} + */ + public function apply(?int $timeout = 600): void { + parent::apply($timeout); + + if (\Drupal::state()->get('system.maintenance_mode')) { + $this->logger->info('Unattended update was applied in maintenance mode.'); + } + } + + /** + * {@inheritdoc} + */ + public function postApply(): void { + if (\Drupal::state()->get('system.maintenance_mode')) { + $this->logger->info('postApply() was called in maintenance mode.'); + } + parent::postApply(); + } + + /** + * {@inheritdoc} + */ + protected function triggerPostApply(string $stage_id): void { + $this->handlePostApply($stage_id); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ConsoleUpdateStageTest.php b/core/modules/auto_updates/tests/src/Kernel/ConsoleUpdateStageTest.php new file mode 100644 index 000000000000..1d5ffbf02257 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ConsoleUpdateStageTest.php @@ -0,0 +1,731 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\Core\Url; +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\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_bypass\LoggingCommitter; +use Drupal\Tests\auto_updates\Traits\EmailNotificationsTestTrait; +use Drupal\Tests\package_manager\Kernel\TestStage; +use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; +use Drupal\Tests\user\Traits\UserCreationTrait; +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\Precondition\Service\PreconditionInterface; +use ColinODell\PsrTestLogger\TestLogger; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @covers \Drupal\auto_updates\ConsoleUpdateStage + * @group auto_updates + * @internal + */ +class ConsoleUpdateStageTest extends AutoUpdatesKernelTestBase { + + use EmailNotificationsTestTrait; + use PackageManagerBypassTestTrait; + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'user', + 'common_test_cron_helper', + ]; + + /** + * The test logger. + * + * @var \ColinODell\PsrTestLogger\TestLogger + */ + private $logger; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('auto_updates') + ->addLogger($this->logger); + $this->installEntitySchema('user'); + + $this->setUpEmailRecipients(); + $this->assertNoCronRun(); + } + + /** + * Tests that a success email is sent even when post-apply tasks fail. + */ + public function testEmailSentIfPostApplyFails(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + $exception = new \Exception('Error during running post-apply tasks!'); + TestSubscriber1::setException($exception, PostApplyEvent::class); + + $this->runConsoleUpdateStage(); + $this->assertNoCronRun(); + $this->assertExceptionLogged($exception->getMessage(), $this->logger); + + // Ensure we sent a success email to all recipients, even though post-apply + // tasks failed. + $expected_body = <<<END +Congratulations! + +Drupal core was automatically updated from 9.8.0 to 9.8.1. + +This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported. + +If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good. +END; + $this->assertMessagesSent("Drupal core was successfully updated", $expected_body); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // Change container to use database lock backends. + $container + ->register('lock', 'Drupal\Core\Lock\DatabaseLockBackend') + ->addArgument(new Reference('database')); + + // Since this test dynamically adds additional loggers to certain channels, + // we need to ensure they will persist even if the container is rebuilt when + // staged changes are applied. + // @see ::testStageDestroyedOnError() + $container->getDefinition('logger.factory')->addTag('persist'); + + // 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 testUpdateStageCalled(). + * + * @return mixed[][] + * The test cases. + */ + public function providerUpdateStageCalled(): array { + $fixture_dir = __DIR__ . '/../../../../package_manager/tests/fixtures/release-history'; + return [ + 'disabled, normal release' => [ + CronUpdateRunner::DISABLED, + ['drupal' => "$fixture_dir/drupal.9.8.2.xml"], + FALSE, + ], + 'disabled, security release' => [ + CronUpdateRunner::DISABLED, + ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"], + FALSE, + ], + 'security only, security release' => [ + CronUpdateRunner::SECURITY, + ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"], + TRUE, + ], + 'security only, normal release' => [ + CronUpdateRunner::SECURITY, + ['drupal' => "$fixture_dir/drupal.9.8.2.xml"], + FALSE, + ], + 'enabled, normal release' => [ + CronUpdateRunner::ALL, + ['drupal' => "$fixture_dir/drupal.9.8.2.xml"], + TRUE, + ], + 'enabled, security release' => [ + CronUpdateRunner::ALL, + ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"], + TRUE, + ], + ]; + } + + /** + * Tests that the cron handler calls the update stage as expected. + * + * @param string $setting + * Whether automatic updates should be enabled during cron. Possible values + * are 'disable', 'security', and 'patch'. + * @param array $release_data + * If automatic updates are enabled, the path of the fake release metadata + * that should be served when fetching information on available updates, + * keyed by project name. + * @param bool $will_update + * Whether an update should be performed, given the previous two arguments. + * + * @dataProvider providerUpdateStageCalled + */ + public function testUpdateStageCalled(string $setting, array $release_data, bool $will_update): void { + $version = strpos($release_data['drupal'], '9.8.2') ? '9.8.2' : '9.8.1'; + if ($will_update) { + $this->getStageFixtureManipulator()->setCorePackageVersion($version); + } + // Our form alter does not refresh information on available updates, so + // ensure that the appropriate update data is loaded beforehand. + $this->setReleaseMetadata($release_data); + $this->setCoreVersion('9.8.0'); + update_get_available(TRUE); + $this->config('auto_updates.settings') + ->set('unattended.level', $setting) + ->save(); + + $this->assertCount(0, $this->container->get(BeginnerInterface::class)->getInvocationArguments()); + // Run cron and ensure that Package Manager's services were called or + // bypassed depending on configuration. + $this->runConsoleUpdateStage(); + + $will_update = (int) $will_update; + $this->assertCount($will_update, $this->container->get(BeginnerInterface::class)->getInvocationArguments()); + // If updates happen, 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 there are any core + // dev requirements (such as `drupal/core-dev`), the stager will also be + // called to update the dev constraints in composer.json. + $this->assertGreaterThanOrEqual($will_update * 2, $this->container->get(StagerInterface::class)->getInvocationArguments()); + $this->assertCount($will_update, $this->container->get(CommitterInterface::class)->getInvocationArguments()); + } + + /** + * Data provider for testStageDestroyedOnError(). + * + * @return string[][] + * The test cases. + */ + public function providerStageDestroyedOnError(): array { + return [ + 'pre-create exception' => [ + PreCreateEvent::class, + 'Exception', + ], + 'post-create exception' => [ + PostCreateEvent::class, + 'Exception', + ], + 'pre-require exception' => [ + PreRequireEvent::class, + 'Exception', + ], + 'post-require exception' => [ + PostRequireEvent::class, + 'Exception', + ], + 'pre-apply exception' => [ + PreApplyEvent::class, + 'Exception', + ], + 'post-apply exception' => [ + PostApplyEvent::class, + 'Exception', + ], + // Only pre-operation events can add validation results. + // @see \Drupal\package_manager\Event\PreOperationStageEvent + // @see \Drupal\package_manager\Stage::dispatch() + 'pre-create validation error' => [ + PreCreateEvent::class, + StageEventException::class, + ], + 'pre-require validation error' => [ + PreRequireEvent::class, + StageEventException::class, + ], + 'pre-apply validation error' => [ + PreApplyEvent::class, + StageEventException::class, + ], + ]; + } + + /** + * Tests that the stage is destroyed if an error occurs during a cron update. + * + * @param string $event_class + * The stage life cycle event which should raise an error. + * @param string $exception_class + * The class of exception that will be thrown when the given event is fired. + * + * @dataProvider providerStageDestroyedOnError + */ + public function testStageDestroyedOnError(string $event_class, string $exception_class): void { + // If the failure happens before the stage is even created, the stage + // fixture need not be manipulated. + if ($event_class !== PreCreateEvent::class) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + } + $this->installConfig('auto_updates'); + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::SECURITY) + ->save(); + // Ensure that there is a security release to which we should update. + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + + // If an exception is thrown during destroy, it will not be caught by the + // cron update runner, but it *will* be caught by the main cron service, + // which will log it as a cron error that we'll want to check for. + $cron_logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('cron') + ->addLogger($cron_logger); + + /** @var \Drupal\auto_updates\ConsoleUpdateStage $stage */ + $stage = $this->container->get(ConsoleUpdateStage::class); + + // When the event specified by $event_class is dispatched, either throw an + // exception directly from the event subscriber, or prepare a + // StageEventException which will format the validation errors its own way. + if ($exception_class === StageEventException::class) { + $error = ValidationResult::createError([ + t('Destroy the stage!'), + ]); + + TestSubscriber1::setTestResult([$error], $event_class); + $exception = $this->createStageEventExceptionFromResults([$error]); + } + else { + $exception = new $exception_class('Destroy the stage!'); + TestSubscriber1::setException($exception, $event_class); + } + + // Ensure that nothing has been logged yet. + $this->assertEmpty($cron_logger->records); + $this->assertEmpty($this->logger->records); + + $this->assertTrue($stage->isAvailable()); + $this->runConsoleUpdateStage(); + + $this->assertTrue($stage->isAvailable()); + $this->assertExceptionLogged($exception->getMessage(), $this->logger); + $this->assertEmpty($cron_logger->records); + } + + /** + * Tests stage is destroyed if not available and site is on insecure version. + */ + public function testStageDestroyedIfNotAvailable(): void { + $stage = $this->createStage(); + $stage_id = $stage->create(); + $original_stage_directory = $stage->getStageDirectory(); + $this->assertDirectoryExists($original_stage_directory); + + $listener = function (PostRequireEvent $event) use (&$cron_stage_dir): void { + $cron_stage_dir = $this->container->get(StagerInterface::class)->getInvocationArguments()[0][1]->absolute(); + $this->assertSame($event->stage->getStageDirectory(), $cron_stage_dir); + $this->assertDirectoryExists($cron_stage_dir); + }; + + $this->addEventTestListener($listener, PostRequireEvent::class); + + $this->runConsoleUpdateStage(); + $this->assertIsString($cron_stage_dir); + $this->assertNotEquals($original_stage_directory, $cron_stage_dir); + $this->assertTrue($this->logger->hasRecord('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.', (string) RfcLogLevel::NOTICE)); + $stage2 = $this->createStage(); + $stage2->create(); + + $this->expectException(StageOwnershipException::class); + $this->expectExceptionMessage('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.'); + $stage->claim($stage_id); + } + + /** + * Tests stage is not destroyed if another update is applying. + */ + public function testStageNotDestroyedIfApplying(): void { + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::ALL) + ->save(); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + $this->setCoreVersion('9.8.0'); + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stop_error = t('Stopping stage from applying'); + + // Add a PreApplyEvent event listener so we can attempt to run cron when + // another stage is applying. + $this->addEventTestListener(function (PreApplyEvent $event) use ($stop_error) { + // Ensure the stage that is applying the operation is not the cron + // update stage. + $this->assertInstanceOf(TestStage::class, $event->stage); + $this->runConsoleUpdateStage(); + // We do not actually want to apply this operation it was just invoked to + // allow cron to be attempted. + $event->addError([$stop_error]); + }); + + try { + $stage->apply(); + $this->fail('Expected update to fail'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException([ValidationResult::createError([$stop_error])], $exception); + } + + $this->assertTrue($this->logger->hasRecord("Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href=\"%url\">update form</a>.", (string) RfcLogLevel::NOTICE)); + $this->assertUpdateStagedTimes(1); + } + + /** + * Tests stage is not destroyed if not available and site is on secure version. + */ + public function testStageNotDestroyedIfSecure(): void { + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::ALL) + ->save(); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + $this->setCoreVersion('9.8.1'); + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/random']); + $this->assertUpdateStagedTimes(1); + + // Trigger CronUpdateRunner, the above should cause it to detect a stage that + // is applying. + $this->runConsoleUpdateStage(); + + $this->assertTrue($this->logger->hasRecord('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.', (string) RfcLogLevel::NOTICE)); + $this->assertUpdateStagedTimes(1); + } + + /** + * Tests that CronUpdateRunner::begin() unconditionally throws an exception. + */ + public function testBeginThrowsException(): void { + $this->expectExceptionMessage(ConsoleUpdateStage::class . '::begin() cannot be called directly.'); + $this->container->get(ConsoleUpdateStage::class) + ->begin(['drupal' => '9.8.1']); + } + + /** + * Tests that email is sent when an unattended update succeeds. + */ + public function testEmailOnSuccess(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $this->runConsoleUpdateStage(); + + // Ensure we sent a success message to all recipients. + $expected_body = <<<END +Congratulations! + +Drupal core was automatically updated from 9.8.0 to 9.8.1. + +This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported. + +If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good. +END; + $this->assertMessagesSent("Drupal core was successfully updated", $expected_body); + } + + /** + * Data provider for ::testEmailOnFailure(). + * + * @return string[][] + * The test cases. + */ + public function providerEmailOnFailure(): array { + return [ + 'pre-create' => [ + PreCreateEvent::class, + ], + 'pre-require' => [ + PreRequireEvent::class, + ], + 'pre-apply' => [ + PreApplyEvent::class, + ], + ]; + } + + /** + * Tests the failure email when an unattended non-security update fails. + * + * @param string $event_class + * The event class that should trigger the failure. + * + * @dataProvider providerEmailOnFailure + */ + public function testNonUrgentFailureEmail(string $event_class): void { + // If the failure happens before the stage is even created, the stage + // fixture need not be manipulated. + if ($event_class !== PreCreateEvent::class) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2'); + } + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::ALL) + ->save(); + + $error = ValidationResult::createError([ + t('Error while updating!'), + ]); + TestSubscriber1::setTestResult([$error], $event_class); + $exception_message = $this->createStageEventExceptionFromResults([$error]) + ->getMessage(); + + $this->runConsoleUpdateStage(); + + $url = Url::fromRoute('update.report_update') + ->setAbsolute() + ->toString(); + + $expected_body = <<<END +Drupal core failed to update automatically from 9.8.0 to 9.8.2. The following error was logged: + +{$exception_message} + +No immediate action is needed, but it is recommended that you visit $url to perform the update, or at least check that everything still looks good. + +This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported. + +If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good. +END; + $this->assertMessagesSent("Drupal core update failed", $expected_body); + } + + /** + * Tests the failure email when an unattended security update fails. + * + * @param string $event_class + * The event class that should trigger the failure. + * + * @dataProvider providerEmailOnFailure + */ + public function testSecurityUpdateFailureEmail(string $event_class): void { + // If the failure happens before the stage is even created, the stage + // fixture need not be manipulated. + if ($event_class !== PreCreateEvent::class) { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + } + + $error = ValidationResult::createError([ + t('Error while updating!'), + ]); + TestSubscriber1::setTestResult([$error], $event_class); + $exception_message = $this->createStageEventExceptionFromResults([$error]) + ->getMessage(); + + $this->runConsoleUpdateStage(); + + $url = Url::fromRoute('update.report_update') + ->setAbsolute() + ->toString(); + + $expected_body = <<<END +Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged: + +{$exception_message} + +Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit $url to perform the update. + +This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported. + +If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good. +END; + $this->assertMessagesSent("URGENT: Drupal core update failed", $expected_body); + } + + /** + * Tests the failure email when an unattended update fails to apply. + */ + public function testApplyFailureEmail(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $error = new \LogicException('I drink your milkshake!'); + LoggingCommitter::setException($error); + + $this->runConsoleUpdateStage(); + $expected_body = <<<END +Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged: + +Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. Caused by LogicException, with this message: {$error->getMessage()} + +This email was sent by the Automatic Updates module. Unattended updates are not yet fully supported. + +If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good. +END; + $this->assertMessagesSent('URGENT: Drupal core update failed', $expected_body); + } + + /** + * Tests that setLogger is called on the cron update runner service. + */ + public function testLoggerIsSetByContainer(): void { + $stage_method_calls = $this->container->getDefinition(CronUpdateRunner::class)->getMethodCalls(); + $this->assertSame('setLogger', $stage_method_calls[0][0]); + } + + /** + * Tests that maintenance mode is on when staged changes are applied. + */ + public function testMaintenanceModeIsOnDuringApply(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + /** @var \Drupal\Core\State\StateInterface $state */ + $state = $this->container->get('state'); + // Before the update begins, we should have no indication that we have ever + // been in maintenance mode (i.e., the value in state is NULL). + $this->assertNull($state->get('system.maintenance_mode')); + $this->runConsoleUpdateStage(); + $state->resetCache(); + // @see \Drupal\Tests\auto_updates\Kernel\TestCronUpdateRunner::apply() + $this->assertTrue($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO)); + // @see \Drupal\Tests\auto_updates\Kernel\TestCronUpdateRunner::postApply() + $this->assertTrue($this->logger->hasRecord('postApply() was called in maintenance mode.', RfcLogLevel::INFO)); + // During post-apply, maintenance mode should have been explicitly turned + // off (i.e., set to FALSE). + $this->assertFalse($state->get('system.maintenance_mode')); + } + + /** + * Data provider for ::testMaintenanceModeAffectedByException(). + * + * @return array[] + * The test cases. + */ + public function providerMaintenanceModeAffectedByException(): array { + return [ + [InvalidArgumentException::class, FALSE], + [PreconditionException::class, FALSE], + [\Exception::class, TRUE], + ]; + } + + /** + * Tests that an exception during apply may keep the site in maintenance mode. + * + * @param string $exception_class + * The class of the exception that should be thrown by the committer. + * @param bool $will_be_in_maintenance_mode + * Whether or not the site will be in maintenance mode afterward. + * + * @dataProvider providerMaintenanceModeAffectedByException + */ + public function testMaintenanceModeAffectedByException(string $exception_class, bool $will_be_in_maintenance_mode): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + $message = $this->createComposeStagerMessage('A fail whale upon your head!'); + LoggingCommitter::setException(match ($exception_class) { + InvalidArgumentException::class => + new InvalidArgumentException($message), + PreconditionException::class => + new PreconditionException($this->createMock(PreconditionInterface::class), $message), + default => + new $exception_class((string) $message), + }); + + /** @var \Drupal\Core\State\StateInterface $state */ + $state = $this->container->get('state'); + $this->assertNull($state->get('system.maintenance_mode')); + $this->runConsoleUpdateStage(); + $this->assertFalse($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO)); + $this->assertSame($will_be_in_maintenance_mode, $state->get('system.maintenance_mode')); + } + + /** + * Tests that the cron lock is acquired and released during an update. + */ + public function testCronIsLockedDuringUpdate(): void { + $lock_checked_on_events = []; + $lock = $this->container->get('lock'); + + // Add listeners to ensure the cron lock is acquired at the beginning of the + // update and only released in post-apply. + $lock_checker = function (StageEvent $event) use (&$lock_checked_on_events, $lock) { + // The lock should not be available, since it should have been acquired + // by the stage before pre-create, and released after post-apply. + $this->assertFalse($lock->lockMayBeAvailable('cron')); + $lock_checked_on_events[] = get_class($event); + }; + $this->addEventTestListener($lock_checker, PreCreateEvent::class); + $this->addEventTestListener($lock_checker, PostApplyEvent::class); + + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + // Ensure that the cron lock is available before the update attempt. + $this->assertTrue($lock->lockMayBeAvailable('cron')); + $this->runConsoleUpdateStage(); + // Ensure the lock was checked on pre-create and post-apply. + $this->assertSame([PreCreateEvent::class, PostApplyEvent::class], $lock_checked_on_events); + $this->assertTrue($lock->lockMayBeAvailable('cron')); + + // Ensure that the cron lock is released when there is exception in the + // update. + $listener = function (): never { + throw new \Exception('Nope!'); + }; + $this->addEventTestListener($listener, PostCreateEvent::class); + $lock_checked_on_events = []; + $this->runConsoleUpdateStage(); + $this->assertExceptionLogged('Nope!', $this->logger); + $this->assertTrue($lock->lockMayBeAvailable('cron')); + $this->assertSame([PreCreateEvent::class], $lock_checked_on_events); + } + + /** + * Asserts cron has not run. + * + * @see \common_test_cron_helper_cron() + */ + private function assertNoCronRun(): void { + $this->assertNull($this->container->get('state')->get('common_test.cron')); + } + + /** + * Creates a StageEventException from a particular set of validation results. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * The validation results associated with the exception. + * + * @return \Drupal\package_manager\Exception\StageEventException + * A stage exception carrying the given validation results. + */ + private function createStageEventExceptionFromResults(array $results): StageEventException { + // Different stage events are constructed with different arguments. Rather + // than encode all that here, just create a generic stage event which + // can carry validation results, and use it to generate the exception. + $event = new class ( + $this->container->get(ConsoleUpdateStage::class), + ) extends PreOperationStageEvent {}; + + array_walk($results, $event->addResult(...)); + return new StageEventException($event); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdateRunnerTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdateRunnerTest.php new file mode 100644 index 000000000000..6f0db42bba45 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdateRunnerTest.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\auto_updates\CronUpdateRunner; + +/** + * @coversDefaultClass \Drupal\auto_updates\CronUpdateRunner + * @group auto_updates + * @internal + */ +class CronUpdateRunnerTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'user', + 'common_test_cron_helper', + ]; + + /** + * Tests that hook_cron implementations are always invoked. + * + * @covers ::run + */ + public function testHookCronInvoked(): void { + // Delete the state value set when cron runs to ensure next asserts start + // from a good state. + // @see \common_test_cron_helper_cron() + $this->container->get('state')->delete('common_test.cron'); + + // Undo override of the 'serverApi' property from the parent test class. + // @see \Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase::setUp + $property = new \ReflectionProperty(CronUpdateRunner::class, 'serverApi'); + $property->setValue(NULL, 'cli'); + $this->assertTrue(CronUpdateRunner::isCommandLine()); + + // Since we're at the command line, the terminal command should not be + // invoked. Since we are in a kernel test, there would be an exception + // if that happened. + // @see \Drupal\Tests\auto_updates\Kernel\TestCronUpdateRunner::runTerminalUpdateCommand + $this->container->get('cron')->run(); + // Even though the terminal command was not invoked, hook_cron + // implementations should have been run. + $this->assertCronRan(); + + // If we are on the web but the method is set to 'console' the terminal + // command should not be invoked. + $property->setValue(NULL, 'cgi-fcgi'); + $this->assertFalse(CronUpdateRunner::isCommandLine()); + $this->config('auto_updates.settings') + ->set('unattended.method', 'console') + ->save(); + $this->container->get('cron')->run(); + $this->assertCronRan(); + + // If we are on the web and method settings is 'web' the terminal command + // should be invoked. + $this->config('auto_updates.settings') + ->set('unattended.method', 'web') + ->save(); + try { + $this->container->get('cron')->run(); + $this->fail('Expected an exception when running updates via cron.'); + } + catch (\BadMethodCallException $e) { + $this->assertSame(TestCronUpdateRunner::class, $e->getMessage()); + } + // Even though the terminal command threw exception hook_cron + // implementations should have been invoked before this. + $this->assertCronRan(); + } + + /** + * Asserts hook_cron implementations were invoked. + * + * @see \common_test_cron_helper_cron() + */ + private function assertCronRan(): void { + $this->assertTrue( + $this->container->get('module_handler')->moduleExists('common_test_cron_helper'), + '\Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase::assertCronRan can only be used if common_test_cron_helper is enabled.' + ); + $state = $this->container->get('state'); + $this->assertSame('success', $state->get('common_test.cron')); + // Delete the value so this function can be called again after the next cron + // attempt. + $state->delete('common_test.cron'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php new file mode 100644 index 000000000000..a6049fb6cee0 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php @@ -0,0 +1,202 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\ReleaseChooser; +use Drupal\auto_updates\UpdateStage; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\update\ProjectRelease; + +/** + * @coversDefaultClass \Drupal\auto_updates\ReleaseChooser + * @group auto_updates + * @internal + */ +class ReleaseChooserTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml', + ]); + } + + /** + * Data provider for testReleases(). + * + * @return mixed[][] + * The test cases. + */ + public function providerReleases(): array { + return [ + 'installed 9.8.0, no minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.8.0, minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.7.0, no minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'installed 9.7.0, minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => '9.8.2', + ], + 'installed 9.7.2, no minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'installed 9.7.2, minor support' => [ + 'stage' => UpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => '9.8.2', + ], + 'cron, installed 9.8.0, no minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.8.0, minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, no minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, no minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, minor support' => [ + 'stage' => ConsoleUpdateStage::class, + 'minor_support' => TRUE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + ]; + } + + /** + * Tests fetching the recommended release when an update is available. + * + * @param string $stage_service + * The ID of the update stage service to use. + * @param bool $minor_support + * Whether updates to the next minor will be allowed. + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $current_minor + * The expected release in the currently installed minor or NULL if none is + * available. + * @param string|null $next_minor + * The expected release in the next minor or NULL if none is available. + * + * @dataProvider providerReleases + * + * @covers ::getLatestInInstalledMinor + * @covers ::getLatestInNextMinor + * @covers ::getMostRecentReleaseInMinor + */ + public function testReleases(string $stage_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void { + $this->setCoreVersion($installed_version); + $this->config('auto_updates.settings')->set('allow_core_minor_updates', $minor_support)->save(); + $chooser = $this->container->get(ReleaseChooser::class); + $stage = $this->container->get($stage_service); + $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor($stage)); + $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor($stage)); + + $this->assertReleaseVersion($current_minor, $chooser->getMostRecentReleaseInMinor($stage, $this->getRelativeVersion($installed_version, 0))); + $next_minor_version = $this->getRelativeVersion($installed_version, 1); + $this->assertReleaseVersion($next_minor, $chooser->getMostRecentReleaseInMinor($stage, $next_minor_version)); + $previous_minor_version = $this->getRelativeVersion($installed_version, -1); + // The chooser should never return a release for a minor before the + // currently installed version. + $this->assertReleaseVersion(NULL, $chooser->getMostRecentReleaseInMinor($stage, $previous_minor_version)); + } + + /** + * Asserts that a project release matches a version number. + * + * @param string|null $version + * The version to check, or NULL if no version expected. + * @param \Drupal\update\ProjectRelease|null $release + * The release to check, or NULL if no release is expected. + */ + private function assertReleaseVersion(?string $version, ?ProjectRelease $release): void { + if (is_null($version)) { + $this->assertNull($release); + } + else { + $this->assertNotEmpty($release); + $this->assertSame($version, $release->getVersion()); + } + } + + /** + * Gets a version number in a minor version relative to another version. + * + * @param string $version + * The version string. + * @param int $minor_offset + * The minor offset. + * + * @return string + * The first patch release in a minor relative to the version string. + */ + private function getRelativeVersion(string $version, int $minor_offset): string { + $installed_version_object = ExtensionVersion::createFromVersionString($version); + return $installed_version_object->getMajorVersion() . '.' . (((int) $installed_version_object->getMinorVersion()) + $minor_offset) . '.0'; + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php new file mode 100644 index 000000000000..24c690f94fa9 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use Drupal\Tests\auto_updates\Kernel\TestCronUpdateRunner; + +/** + * @covers \Drupal\auto_updates\Validator\CronFrequencyValidator + * @group auto_updates + * @internal + */ +class CronFrequencyValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + // In this test, we do not want to do an update. We're just testing that + // cron is configured to run frequently enough to do automatic updates. So, + // pretend we're already on the latest secure version of core. + $this->setCoreVersion('9.8.1'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + } + + /** + * Tests that nothing is validated if not needed. + */ + public function testNoValidation(): void { + $state = $this->container->get('state'); + $state->delete('system.cron_last'); + $state->delete('install_time'); + + // If the method is 'web' but cron updates are disabled no validation is + // needed. + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::DISABLED) + ->set('unattended.method', 'web') + ->save(); + $this->assertCheckerResultsFromManager([], TRUE); + + // If cron updates are enabled but the method is 'console' no validation is + // needed. + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::ALL) + ->set('unattended.method', 'console') + ->save(); + $this->assertCheckerResultsFromManager([], TRUE); + + // If cron updates are enabled and the method is 'web' validation is needed. + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::ALL) + ->set('unattended.method', 'web') + ->save(); + $error = ValidationResult::createError([ + t('Cron has not run recently. For more information, see the online handbook entry for <a href="https://www.drupal.org/cron">configuring cron jobs</a> to run at least every 3 hours.'), + ]); + $this->assertCheckerResultsFromManager([$error], TRUE); + } + + /** + * Data provider for testLastCronRunValidation(). + * + * @return mixed[][] + * The test cases. + */ + public function providerLastCronRunValidation(): array { + $error = ValidationResult::createError([ + t('Cron has not run recently. For more information, see the online handbook entry for <a href="https://www.drupal.org/cron">configuring cron jobs</a> to run at least every 3 hours.'), + ]); + + return [ + 'cron never ran' => [ + 0, + [$error], + ], + 'cron ran four hours ago' => [ + time() - 14400, + [$error], + ], + 'cron ran an hour ago' => [ + time() - 3600, + [], + ], + ]; + } + + /** + * Tests validation based on the last cron run time. + * + * @param int $last_run + * A timestamp of the last time cron ran. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerLastCronRunValidation + */ + public function testLastCronRunValidation(int $last_run, array $expected_results): void { + $this->container->get('state')->set('system.cron_last', $last_run); + $this->assertCheckerResultsFromManager($expected_results, TRUE); + + try { + $this->container->get('cron')->run(); + $this->fail('Expected an exception but one was not thrown.'); + } + catch (\BadMethodCallException $e) { + // The terminal command cannot be run in a kernel test. + // @see \Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase::runUpdateViaConsole + $this->assertSame(TestCronUpdateRunner::class, $e->getMessage()); + // After cron runs, the validator should detect that cron ran recently. + // Even though the terminal command did not succeed, the decorated cron + // service from the System module should have been called before the + // attempt to run the terminal command. + // @see \Drupal\auto_updates\CronUpdateRunner::run + $this->assertCheckerResultsFromManager([], TRUE); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php new file mode 100644 index 000000000000..ffbcb9147870 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; +use ColinODell\PsrTestLogger\TestLogger; + +/** + * @covers \Drupal\auto_updates\Validator\PhpExtensionsValidator + * @group auto_updates + * @internal + */ +class PhpExtensionsValidatorTest extends AutoUpdatesKernelTestBase { + + use PackageManagerBypassTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * Tests warnings and/or errors if Xdebug is enabled. + */ + public function testXdebugValidation(): void { + $this->simulateXdebugEnabled(); + + // Package Manager meekly warns about reduced performance when Xdebug is + // enabled; Automatic Updates will actually prevent unattended updates. + $warning_result = ValidationResult::createWarning([ + t('Xdebug is enabled, which may have a negative performance impact on Package Manager and any modules that use it.'), + ]); + $error_result = ValidationResult::createError([ + t("Unattended updates are not allowed while Xdebug is enabled. You cannot receive updates, including security updates, until it is disabled."), + ]); + + $config = $this->config('auto_updates.settings'); + + // If unattended updates are disabled, we should only see a warning from + // Package Manager. + $config->set('unattended.level', CronUpdateRunner::DISABLED)->save(); + $this->assertCheckerResultsFromManager([$warning_result], TRUE); + + // The parent class' setUp() method simulates an available security update, + // so ensure that the cron update runner will try to update to it. + $config->set('unattended.level', CronUpdateRunner::SECURITY)->save(); + + // If unattended updates are enabled, we should see an error from Automatic + // Updates. + $this->assertCheckerResultsFromManager([$error_result], TRUE); + + // Trying to do the update during cron should fail with an error. + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('auto_updates') + ->addLogger($logger); + + $this->runConsoleUpdateStage(); + // The update should have been stopped before it started. + $this->assertUpdateStagedTimes(0); + $this->assertExceptionLogged((string) $error_result->messages[0], $logger); + } + + /** + * Tests warnings and/or errors if Xdebug is enabled during pre-apply. + */ + public function testXdebugValidationDuringPreApply(): void { + // Xdebug will be enabled during pre-apply. + $this->addEventTestListener($this->simulateXdebugEnabled(...)); + + // The parent class' setUp() method simulates an available security + // update, so ensure that the cron update runner will try to update to it. + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::SECURITY) + ->save(); + + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('auto_updates') + ->addLogger($logger); + + $this->runConsoleUpdateStage(); + // The update should have been staged, but then stopped with an error. + $this->assertUpdateStagedTimes(1); + $this->assertExceptionLogged("Unattended updates are not allowed while Xdebug is enabled. You cannot receive updates, including security updates, until it is disabled.", $logger); + } + + /** + * Simulating that xdebug is enabled. + */ + private function simulateXdebugEnabled(): void { + // @see \Drupal\package_manager\Validator\PhpExtensionsValidator::isExtensionLoaded() + $this->container->get('state') + ->set('package_manager_loaded_php_extensions', ['xdebug', 'openssl']); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/RequestedUpdateValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/RequestedUpdateValidatorTest.php new file mode 100644 index 000000000000..64be59c03fb2 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/RequestedUpdateValidatorTest.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\UpdateStage; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @coversDefaultClass \Drupal\auto_updates\Validator\RequestedUpdateValidator + * @group auto_updates + * @internal + */ +class RequestedUpdateValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + ]; + + /** + * Tests error message is shown if the core version is not updated. + */ + public function testErrorMessageOnCoreNotUpdated(): void { + // Update `drupal/core-recommended` to a version that does not match the + // requested version of '9.8.1'. This also does not update all packages that + // are expected to be updated when updating Drupal core. + // @see \Drupal\auto_updates\UpdateStage::begin() + // @see \Drupal\package_manager\InstalledPackagesList::getCorePackages() + $this->getStageFixtureManipulator()->setVersion('drupal/core-recommended', '9.8.2'); + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + $this->container->get('module_installer')->install(['auto_updates']); + + $stage = $this->container->get(UpdateStage::class); + $expected_results = [ + ValidationResult::createError([t("The requested update to 'drupal/core-recommended' to version '9.8.1' does not match the actual staged update to '9.8.2'.")]), + ValidationResult::createError([t("The requested update to 'drupal/core-dev' to version '9.8.1' was not performed.")]), + ]; + $stage->begin(['drupal' => '9.8.1']); + $this->assertStatusCheckResults($expected_results, $stage); + $stage->stage(); + try { + $stage->apply(); + $this->fail('Expecting an exception.'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException($expected_results, $exception); + } + } + + /** + * Tests error message is shown if there are no core packages in stage. + */ + public function testErrorMessageOnEmptyCorePackages(): void { + $this->getStageFixtureManipulator() + ->removePackage('drupal/core') + ->removePackage('drupal/core-recommended') + ->removePackage('drupal/core-dev', TRUE); + + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + $this->container->get('module_installer')->install(['auto_updates']); + + $expected_results = [ + ValidationResult::createError([t('No updates detected in the staging area.')]), + ]; + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $this->assertStatusCheckResults($expected_results, $stage); + $stage->stage(); + try { + $stage->apply(); + $this->fail('Expecting an exception.'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException($expected_results, $exception); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php new file mode 100644 index 000000000000..d0a2aaf48475 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use ColinODell\PsrTestLogger\TestLogger; + +/** + * @covers \Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator + * @group auto_updates + * @internal + */ +class StagedDatabaseUpdateValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * Tests that unattended updates are stopped by staged database updates. + */ + public function testStagedDatabaseUpdateExists(): void { + $logger = new TestLogger(); + $this->container->get('logger.channel.auto_updates') + ->addLogger($logger); + + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + $listener = function (PreApplyEvent $event): void { + $dir = $event->stage->getStageDirectory() . '/core/modules/system'; + mkdir($dir, 0777, TRUE); + file_put_contents($dir . '/system.install', "<?php\nfunction system_update_10101010() {}"); + }; + $this->addEventTestListener($listener); + + $this->runConsoleUpdateStage(); + $this->assertExceptionLogged("The update cannot proceed because database updates have been detected in the following extensions.\nSystem\n", $logger); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php new file mode 100644 index 000000000000..6bd88759c9a4 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php @@ -0,0 +1,328 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\UpdateStage; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\SupportedReleaseValidator; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @covers \Drupal\auto_updates\Validator\StagedProjectsValidator + * @group auto_updates + * @internal + */ +class StagedProjectsValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * {@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(); + } + + /** + * Tests that an error is raised if Drupal extensions are unexpectedly added. + */ + public function testProjectsAdded(): void { + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'drupal/test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => 'other/removed', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'drupal/dev-test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'other/dev-removed', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator + ->setCorePackageVersion('9.8.1') + ->addPackage([ + 'name' => 'drupal/test-module2', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ]) + ->addPackage( + [ + 'name' => 'drupal/dev-test-module2', + 'version' => '1.3.1', + 'type' => 'drupal-custom-module', + ], + TRUE + ) + // The validator shouldn't complain about these packages being added or + // removed, since it only cares about Drupal modules and themes. + ->addPackage([ + 'name' => 'other/new_project', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'other/dev-new_project', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->removePackage('other/removed') + ->removePackage('other/dev-removed', TRUE); + + $messages = [ + t("custom module 'drupal/dev-test-module2' installed."), + t("module 'drupal/test-module2' installed."), + ]; + $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were installed during the update.')); + + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $stage->stage(); + try { + $stage->apply(); + $this->fail('Expected an error, but none was raised.'); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException([$error], $e); + } + } + + /** + * Tests that errors are raised if Drupal extensions are unexpectedly removed. + */ + public function testProjectsRemoved(): void { + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'drupal/test_theme', + 'version' => '1.3.0', + 'type' => 'drupal-theme', + ]) + ->addPackage([ + 'name' => 'drupal/test-module2', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => 'other/removed', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'drupal/dev-test_theme', + 'version' => '1.3.0', + 'type' => 'drupal-custom-theme', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'drupal/dev-test-module2', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'other/dev-removed', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->removePackage('drupal/test_theme') + ->removePackage('drupal/dev-test_theme', TRUE) + // The validator shouldn't complain about these packages being removed, + // since it only cares about Drupal modules and themes. + ->removePackage('other/removed') + ->removePackage('other/dev-removed', TRUE) + ->setCorePackageVersion('9.8.1'); + + $messages = [ + t("custom theme 'drupal/dev-test_theme' removed."), + t("theme 'drupal/test_theme' removed."), + ]; + $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were removed during the update.')); + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $stage->stage(); + try { + $stage->apply(); + $this->fail('Expected an error, but none was raised.'); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException([$error], $e); + } + } + + /** + * Tests that errors are raised if Drupal extensions are unexpectedly updated. + */ + public function testVersionsChanged(): void { + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'drupal/test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => 'other/changed', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'drupal/dev-test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'other/dev-changed', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->setVersion('drupal/test-module', '1.3.1') + ->setVersion('drupal/dev-test-module', '1.3.1') + // The validator shouldn't complain about these packages being updated, + // because it only cares about Drupal modules and themes. + ->setVersion('other/changed', '1.3.2') + ->setVersion('other/dev-changed', '1.3.2') + ->setCorePackageVersion('9.8.1'); + + $messages = [ + t("module 'drupal/dev-test-module' from 1.3.0 to 1.3.1."), + t("module 'drupal/test-module' from 1.3.0 to 1.3.1."), + ]; + $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.')); + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $stage->stage(); + + try { + $stage->apply(); + $this->fail('Expected an error, but none was raised.'); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException([$error], $e); + } + } + + /** + * Tests that no errors occur if only core and its dependencies are updated. + */ + public function testNoErrors(): void { + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'drupal/test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => 'other/removed', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage([ + 'name' => 'other/changed', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'drupal/dev-test-module', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'other/dev-removed', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->addPackage( + [ + 'name' => 'other/dev-changed', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->setCorePackageVersion('9.8.1') + // The validator shouldn't care what happens to these packages, since it + // only concerns itself with Drupal modules and themes. + ->addPackage([ + 'name' => 'other/new_project', + 'version' => '1.3.1', + 'type' => 'library', + ]) + ->addPackage( + [ + 'name' => 'other/dev-new_project', + 'version' => '1.3.1', + 'type' => 'library', + ], + TRUE + ) + ->setVersion('other/changed', '1.3.2') + ->setVersion('other/dev-changed', '1.3.2') + ->removePackage('other/removed') + ->removePackage('other/dev-removed', TRUE); + + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $stage->stage(); + $stage->apply(); + $this->assertTrue(TRUE); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StatusCheckerTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StatusCheckerTest.php new file mode 100644 index 000000000000..dd98dc3b540e --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/StatusCheckerTest.php @@ -0,0 +1,303 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\UpdateStage; +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\auto_updates\Validator\StagedProjectsValidator; +use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\auto_updates_test_status_checker\EventSubscriber\TestSubscriber2; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @coversDefaultClass \Drupal\auto_updates\Validation\StatusChecker + * @group auto_updates + * @internal + */ +class StatusCheckerTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates_test', + 'package_manager_test_validation', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setCoreVersion('9.8.2'); + $this->installEntitySchema('user'); + $this->installSchema('user', ['users_data']); + + // Undoes the override in parent::setUp(), to allow the module to be + // installed, which every other test methods in this class does. Without + // this \Drupal\Core\Config\PreExistingConfigException is thrown. + // @todo Remove in https://www.drupal.org/project/auto_updates/issues/3284443 + $this->config('auto_updates.settings')->delete(); + } + + /** + * @covers ::getResults + */ + public function testGetResults(): void { + $this->container->get('module_installer') + ->install(['auto_updates', 'auto_updates_test_status_checker']); + $this->assertCheckerResultsFromManager([], TRUE); + $checker_1_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + $checker_2_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_expected, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_expected, StatusCheckEvent::class); + $expected_results_all = array_merge($checker_1_expected, $checker_2_expected); + $this->assertCheckerResultsFromManager($expected_results_all, TRUE); + + // Define a constant flag that will cause the status checker service + // priority to be altered. + define('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY', 1); + // Rebuild the container to trigger the service to be altered. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + // The stored results should be returned, even though the validators' order + // has been changed and the container has been rebuilt. + $this->assertValidationResultsEqual($expected_results_all, $this->getResultsFromManager()); + // Confirm that after calling run() the expected results order has changed. + $expected_results_all_reversed = array_reverse($expected_results_all); + $this->assertCheckerResultsFromManager($expected_results_all_reversed, TRUE); + + $checker_1_expected = [ + 'checker 1 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR), + 'checker 1 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + $checker_2_expected = [ + 'checker 2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR), + 'checker 2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING), + ]; + TestSubscriber1::setTestResult($checker_1_expected, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_expected, StatusCheckEvent::class); + $expected_results_all = array_merge($checker_2_expected, $checker_1_expected); + $this->assertCheckerResultsFromManager($expected_results_all, TRUE); + + // Confirm that filtering by severity works. + $warnings_only_results = [ + $checker_2_expected['checker 2 warnings'], + $checker_1_expected['checker 1 warnings'], + ]; + $this->assertCheckerResultsFromManager($warnings_only_results, FALSE, SystemManager::REQUIREMENT_WARNING); + + $errors_only_results = [ + $checker_2_expected['checker 2 errors'], + $checker_1_expected['checker 1 errors'], + ]; + $this->assertCheckerResultsFromManager($errors_only_results, FALSE, SystemManager::REQUIREMENT_ERROR); + } + + /** + * Tests that the manager is run after modules are installed. + */ + public function testRunOnInstall(): void { + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + // Confirm that messages from an existing module are displayed when + // 'auto_updates' is installed. + $this->container->get('module_installer')->install(['auto_updates']); + $this->assertCheckerResultsFromManager($checker_1_results); + + // Confirm that the checkers are run when a module that provides a status + // checker is installed. + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_results, StatusCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates_test_status_checker']); + $expected_results_all = array_merge($checker_1_results, $checker_2_results); + $this->assertCheckerResultsFromManager($expected_results_all); + + // Confirm that the checkers are run when a module that does not provide a + // status checker is installed. + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_results, StatusCheckEvent::class); + $expected_results_all = array_merge($checker_1_results, $checker_2_results); + $this->container->get('module_installer')->install(['help']); + $this->assertCheckerResultsFromManager($expected_results_all); + } + + /** + * Tests that the manager is run after modules are uninstalled. + */ + public function testRunOnUninstall(): void { + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_results, StatusCheckEvent::class); + // Confirm that messages from existing modules are displayed when + // 'auto_updates' is installed. + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test_status_checker', 'help']); + $expected_results_all = array_merge($checker_1_results, $checker_2_results); + $this->assertCheckerResultsFromManager($expected_results_all); + + // Confirm that the checkers are run when a module that provides a status + // checker is uninstalled. + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + TestSubscriber2::setTestResult($checker_2_results, StatusCheckEvent::class); + $this->container->get('module_installer')->uninstall(['auto_updates_test_status_checker']); + $this->assertCheckerResultsFromManager($checker_1_results); + + // Confirm that the checkers are run when a module that does not provide a + // status checker is uninstalled. + $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($checker_1_results, StatusCheckEvent::class); + $this->container->get('module_installer')->uninstall(['help']); + $this->assertCheckerResultsFromManager($checker_1_results); + } + + /** + * @covers ::runIfNoStoredResults + * @covers ::clearStoredResults + */ + public function testRunIfNeeded(): void { + $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test_status_checker']); + $this->assertCheckerResultsFromManager($expected_results); + + $unexpected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($unexpected_results, StatusCheckEvent::class); + $manager = $this->container->get(StatusChecker::class); + // Confirm that the new results will not be returned because the checkers + // will not be run. + $manager->runIfNoStoredResults(); + $this->assertCheckerResultsFromManager($expected_results); + + // Confirm that the new results will be returned because the checkers will + // be run if the stored results are deleted. + $manager->clearStoredResults(); + $expected_results = $unexpected_results; + $manager->runIfNoStoredResults(); + $this->assertCheckerResultsFromManager($expected_results); + + // Confirm that the results are the same after rebuilding the container. + $unexpected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($unexpected_results, StatusCheckEvent::class); + /** @var \Drupal\Core\DrupalKernel $kernel */ + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $this->assertCheckerResultsFromManager($expected_results); + } + + /** + * Tests the Automatic Updates cron setting changes which stage class is used. + */ + public function testCronSetting(): void { + $this->enableModules(['auto_updates']); + $stage = NULL; + $listener = function (StatusCheckEvent $event) use (&$stage): void { + $stage = $event->stage; + }; + $this->addEventTestListener($listener, StatusCheckEvent::class); + $this->container->get(StatusChecker::class)->run(); + // By default, updates will be enabled on cron. + $this->assertInstanceOf(ConsoleUpdateStage::class, $stage); + $this->config('auto_updates.settings') + ->set('unattended.level', CronUpdateRunner::DISABLED) + ->save(); + $this->container->get(StatusChecker::class)->run(); + $this->assertInstanceOf(UpdateStage::class, $stage); + } + + /** + * Tests that stored validation results are deleted after an update. + */ + public function testStoredResultsDeletedPostApply(): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + $this->container->get('module_installer')->install(['auto_updates']); + + // The status checker should raise a warning, so that the update is not + // blocked or aborted. + $results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)]; + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + + // Ensure that the validation manager collects the warning. + $manager = $this->container->get(StatusChecker::class) + ->run(); + $this->assertValidationResultsEqual($results, $manager->getResults()); + TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class); + // Even though the checker no longer returns any results, the previous + // results should be stored. + $this->assertValidationResultsEqual($results, $manager->getResults()); + + // Don't validate staged projects because actual stage operations are + // bypassed by package_manager_bypass, which will make this validator + // complain that there is no actual Composer data for it to inspect. + $validator = $this->container->get(StagedProjectsValidator::class); + $this->container->get('event_dispatcher')->removeSubscriber($validator); + + $stage = $this->container->get(UpdateStage::class); + $stage->begin(['drupal' => '9.8.1']); + $stage->stage(); + $stage->apply(); + $stage->postApply(); + $stage->destroy(); + + // The status validation manager shouldn't have any stored results. + $this->assertEmpty($manager->getResults()); + } + + /** + * Tests that certain config changes clear stored results. + */ + public function testStoredResultsClearedOnConfigChanges(): void { + $this->container->get('module_installer')->install(['auto_updates']); + + $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)]; + TestSubscriber1::setTestResult($results, StatusCheckEvent::class); + $this->assertCheckerResultsFromManager($results, TRUE); + // The results should be stored. + $this->assertCheckerResultsFromManager($results, FALSE); + // Changing the configured path to rsync should not clear the results. + $this->config('package_manager.settings') + ->set('executables.rsync', '/path/to/rsync') + ->save(); + $this->assertCheckerResultsFromManager($results, FALSE); + // Changing the configured path to Composer should clear the results. + $this->config('package_manager.settings') + ->set('executables.composer', '/path/to/composer') + ->save(); + $this->assertNull($this->getResultsFromManager(FALSE)); + } + + /** + * @covers ::getLastRunTime + */ + public function testLastRunTime(): void { + $this->enableModules(['auto_updates']); + + /** @var \Drupal\auto_updates\Validation\StatusChecker $status_checker */ + $status_checker = $this->container->get(StatusChecker::class); + $this->assertNull($status_checker->getLastRunTime()); + $status_checker->run(); + $last_run_time = $status_checker->getLastRunTime(); + $this->assertIsInt($last_run_time); + $status_checker->clearStoredResults(); + // The last run time should be unaffected by clearing stored results. + $this->assertSame($last_run_time, $status_checker->getLastRunTime()); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicy/SupportedBranchInstalledTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicy/SupportedBranchInstalledTest.php new file mode 100644 index 000000000000..a101f075fc70 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicy/SupportedBranchInstalledTest.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled + * @group auto_updates + * @internal + */ +class SupportedBranchInstalledTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * Data provider for testSupportedBranchInstalled(). + * + * @return mixed[][] + * The test cases. + */ + public function providerSupportedBranchInstalled(): array { + return [ + 'supported minor installed' => [ + '9.8.0', + [FALSE, TRUE], + [], + ], + // These two cases test a supported major version, but unsupported minor + // version. + 'supported major installed, minor updates forbidden' => [ + '9.6.1', + [FALSE], + [ + 'The currently installed version of Drupal core, 9.6.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.', + 'See the <a href="/admin/reports/updates">available updates page</a> for available updates.', + ], + ], + 'supported major installed, minor updates allowed' => [ + '9.6.1', + [TRUE], + [ + 'The currently installed version of Drupal core, 9.6.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.', + 'Use the <a href="/admin/modules/update">update form</a> to update to a supported version.', + ], + ], + 'unsupported version installed' => [ + '8.9.0', + [FALSE, TRUE], + [ + 'The currently installed version of Drupal core, 8.9.0, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.', + 'See the <a href="/admin/reports/updates">available updates page</a> for available updates.', + ], + ], + ]; + } + + /** + * Tests that the installed version of Drupal must be in a supported branch. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param bool[] $allow_minor_updates + * The values of the `allow_core_minor_updates` config setting that should + * be tested. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerSupportedBranchInstalled + */ + public function testSupportedBranchInstalled(string $installed_version, array $allow_minor_updates, array $expected_errors): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + + $rule = SupportedBranchInstalled::create($this->container); + + foreach ($allow_minor_updates as $setting) { + $this->config('auto_updates.settings') + ->set('allow_core_minor_updates', $setting) + ->save(); + + $actual_errors = array_map('strval', $rule->validate($installed_version)); + $this->assertSame($expected_errors, $actual_errors); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php new file mode 100644 index 000000000000..5b6e93c3ca18 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php @@ -0,0 +1,731 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\ConsoleUpdateStage; +use Drupal\auto_updates\UpdateStage; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicyValidator + * @group auto_updates + * @internal + */ +class VersionPolicyValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * Data provider for testStatusCheck(). + * + * @return mixed[][] + * The test cases. + */ + public function providerStatusCheckSpecific(): array { + $metadata_dir = __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history'; + + return [ + // This case proves that, if a stable release is installed, there is no + // error generated when if the next available release is a normal (i.e., + // non-security) release. If unattended updates are only enabled for + // security releases, the next available release will be ignored, and + // therefore generate no validation errors, because it's not a security + // release. + 'update to normal release' => [ + '9.8.1', + NULL, + "$metadata_dir/drupal.9.8.2.xml", + [CronUpdateRunner::DISABLED, CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [], + ], + // These three cases prove that updating from an unsupported minor version + // will raise an error if unattended updates are enabled. Furthermore, if + // an error is raised, the messaging will vary depending on whether + // attended updates across minor versions are allowed. (Note that the + // target version will not be automatically detected because the release + // metadata used in these cases doesn't have any 9.7.x releases.) + 'update from unsupported minor, cron disabled' => [ + '9.7.1', + NULL, + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'update from unsupported minor, cron enabled, minor updates forbidden' => [ + '9.7.1', + NULL, + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.'), + t('See the <a href="/admin/reports/updates">available updates page</a> for available updates.'), + ], + ], + 'update from unsupported minor, cron enabled, minor updates allowed' => [ + '9.7.1', + NULL, + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.'), + t('Use the <a href="/admin/modules/update">update form</a> to update to a supported version.'), + ], + TRUE, + ], + ]; + } + + /** + * Data provider for testStatusCheck() and testCronPreCreate(). + * + * @return mixed[][] + * The test cases. + */ + public function providerGeneric(): array { + $metadata_dir = __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history'; + + return [ + // Updating from a dev, alpha, beta, or RC release is not allowed during + // cron. The first case is a control to prove that a legitimate + // patch-level update from a stable release never raises an error. + 'stable release installed' => [ + '9.8.0', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED, CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [], + ], + // This case proves that updating from a dev snapshot is never allowed, + // regardless of configuration. + 'dev snapshot installed' => [ + '9.8.0-dev', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED, CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'), + ], + ], + // The next six cases prove that updating from an alpha, beta, or RC + // release raises an error if unattended updates are enabled. + 'alpha installed, cron disabled' => [ + '9.8.0-alpha1', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'alpha installed, cron enabled' => [ + '9.8.0-alpha1', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha1, because it is not a stable version.'), + ], + ], + 'beta installed, cron disabled' => [ + '9.8.0-beta2', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'beta installed, cron enabled' => [ + '9.8.0-beta2', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta2, because it is not a stable version.'), + ], + ], + 'rc installed, cron disabled' => [ + '9.8.0-rc3', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'rc installed, cron enabled' => [ + '9.8.0-rc3', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc3, because it is not a stable version.'), + ], + ], + ]; + } + + /** + * Tests target version validation during status checks. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $target_version + * The target version of Drupal core. + * @param string $release_metadata + * The path of the core release metadata to serve to the update system. + * @param string[] $cron_modes + * The modes for unattended updates. Can contain any of + * \Drupal\auto_updates\CronUpdateRunner::DISABLED, + * \Drupal\auto_updates\CronUpdateRunner::SECURITY, and + * \Drupal\auto_updates\CronUpdateRunner::ALL. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_validation_messages + * The expected validation messages. + * @param bool $allow_minor_updates + * (optional) Whether or not attended updates across minor updates are + * allowed. Defaults to FALSE. + * + * @dataProvider providerGeneric + * @dataProvider providerStatusCheckSpecific + */ + public function testStatusCheck(string $installed_version, ?string $target_version, string $release_metadata, array $cron_modes, array $expected_validation_messages, bool $allow_minor_updates = FALSE): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_metadata]); + + foreach ($cron_modes as $cron_mode) { + $this->config('auto_updates.settings') + ->set('unattended.level', $cron_mode) + ->set('allow_core_minor_updates', $allow_minor_updates) + ->save(); + + $expected_results = []; + if ($expected_validation_messages) { + // If we're doing a status check, the stage isn't created, and the + // requested package versions are not recorded during begin(), so the + // error message won't contain the target version. + $expected_results[] = $this->createVersionPolicyValidationResult($installed_version, NULL, $expected_validation_messages); + } + $this->assertCheckerResultsFromManager($expected_results, TRUE); + } + } + + /** + * Data provider for testCronPreCreate(). + * + * @return mixed[][] + * The test cases. + */ + public function providerCronPreCreateSpecific(): array { + $metadata_dir = __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history'; + + return [ + // The next three cases prove that update to an alpha, beta, or RC release + // doesn't raise any error if unattended updates are disabled. + 'update to alpha, cron disabled' => [ + '9.8.0', + '9.8.1-alpha1', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'update to beta, cron disabled' => [ + '9.8.0', + '9.8.1-beta2', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::DISABLED], + [], + ], + 'update to rc, cron disabled' => [ + '9.8.0', + '9.8.1-rc3', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::DISABLED], + [], + ], + // This case proves that, if a stable release is installed, there is an + // error generated when if the next available release is a normal (i.e., + // non-security) release, if unattended updates are only enabled for + // security releases. + 'update to normal release, cron enabled for security releases' => [ + '9.8.1', + '9.8.2', + "$metadata_dir/drupal.9.8.2.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron from 9.8.1 to 9.8.2 because 9.8.2 is not a security release.'), + ], + ], + // The next three cases prove that normal (i.e., non-security) update to + // an alpha, beta, or RC release raises multiple errors if unattended + // updates are enabled only for security releases. + 'normal update to alpha, cron enabled for security releases' => [ + '9.8.0', + '9.8.1-alpha1', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-alpha1, because it is not a stable version.'), + t('Drupal cannot be automatically updated during cron from 9.8.0 to 9.8.1-alpha1 because 9.8.1-alpha1 is not a security release.'), + ], + ], + 'normal update to beta, cron enabled for security releases' => [ + '9.8.0', + '9.8.1-beta2', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-beta2, because it is not a stable version.'), + t('Drupal cannot be automatically updated during cron from 9.8.0 to 9.8.1-beta2 because 9.8.1-beta2 is not a security release.'), + ], + ], + 'normal update to rc, cron enabled for security releases' => [ + '9.8.0', + '9.8.1-rc3', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-rc3, because it is not a stable version.'), + t('Drupal cannot be automatically updated during cron from 9.8.0 to 9.8.1-rc3 because 9.8.1-rc3 is not a security release.'), + ], + ], + // The next three cases prove that normal (i.e., non-security) minor + // updates to an alpha, beta, or RC release raises multiple errors if + // unattended updates are enabled only for security releases. + 'update to alpha of next minor, cron enabled for security releases, minor updates forbidden' => [ + '9.7.0', + '9.8.1-alpha1', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-alpha1, because it is not a stable version.'), + t('Drupal cannot be automatically updated from 9.7.0 to 9.8.1-alpha1 because automatic updates from one minor version to another are not supported during cron.'), + t('Drupal cannot be automatically updated during cron from 9.7.0 to 9.8.1-alpha1 because 9.8.1-alpha1 is not a security release.'), + ], + ], + 'update to beta of next minor, cron enabled for security releases, minor updates forbidden' => [ + '9.7.0', + '9.8.1-beta2', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-beta2, because it is not a stable version.'), + t('Drupal cannot be automatically updated from 9.7.0 to 9.8.1-beta2 because automatic updates from one minor version to another are not supported during cron.'), + t('Drupal cannot be automatically updated during cron from 9.7.0 to 9.8.1-beta2 because 9.8.1-beta2 is not a security release.'), + ], + ], + 'update to rc of next minor, cron enabled for security releases, minor updates forbidden' => [ + '9.7.0', + '9.8.1-rc3', + "$metadata_dir/drupal.9.8.1-extra.xml", + [CronUpdateRunner::SECURITY], + [ + t('Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-rc3, because it is not a stable version.'), + t('Drupal cannot be automatically updated from 9.7.0 to 9.8.1-rc3 because automatic updates from one minor version to another are not supported during cron.'), + t('Drupal cannot be automatically updated during cron from 9.7.0 to 9.8.1-rc3 because 9.8.1-rc3 is not a security release.'), + ], + ], + // These three cases prove that updating from an unsupported minor version + // will raise an error for unattended updates, if unattended updates are + // enabled. Furthermore, if an error is raised, the messaging will vary + // depending on whether attended updates across minor versions are + // allowed. (Note that the target version will not be automatically + // detected because the release metadata used in these cases doesn't have + // any 9.7.x releases.) + 'update from unsupported minor, cron disabled, minor updates forbidden' => [ + '9.7.1', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::DISABLED], + [ + t('Drupal cannot be automatically updated from 9.7.1 to 9.8.1 because automatic updates from one minor version to another are not supported.'), + ], + ], + 'update from unsupported minor, cron enabled, minor updates forbidden' => [ + '9.7.1', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.'), + t('See the <a href="/admin/reports/updates">available updates page</a> for available updates.'), + t('Drupal cannot be automatically updated from 9.7.1 to 9.8.1 because automatic updates from one minor version to another are not supported during cron.'), + ], + ], + 'update from unsupported minor, cron enabled, minor updates allowed' => [ + '9.7.1', + '9.8.1', + "$metadata_dir/drupal.9.8.1-security.xml", + [CronUpdateRunner::SECURITY, CronUpdateRunner::ALL], + [ + t('The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.'), + t('Use the <a href="/admin/modules/update">update form</a> to update to a supported version.'), + t('Drupal cannot be automatically updated from 9.7.1 to 9.8.1 because automatic updates from one minor version to another are not supported during cron.'), + ], + TRUE, + ], + ]; + } + + /** + * Tests target version validation during pre-create. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string $target_version + * The target version of Drupal core. + * @param string $release_metadata + * The path of the core release metadata to serve to the update system. + * @param string[] $cron_modes + * The modes for unattended updates. Can contain any of + * \Drupal\auto_updates\CronUpdateRunner::DISABLED, + * \Drupal\auto_updates\CronUpdateRunner::SECURITY, and + * \Drupal\auto_updates\CronUpdateRunner::ALL. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_validation_messages + * The expected validation messages. + * @param bool $allow_minor_updates + * (optional) Whether or not attended updates across minor updates are + * allowed. Defaults to FALSE. + * + * @dataProvider providerGeneric + * @dataProvider providerCronPreCreateSpecific + */ + public function testCronPreCreate(string $installed_version, string $target_version, string $release_metadata, array $cron_modes, array $expected_validation_messages, bool $allow_minor_updates = FALSE): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_metadata]); + + // On pre-create, make the stage think that we're updating + // drupal/core-recommended to $target_version. We need to do this to test + // version validation during pre-create of an unattended update. We can't + // use StageFixtureManipulator::setCorePackageVersion() for this, because + // that would get executed after pre-create. + // @see \Drupal\auto_updates\Validator\VersionPolicyValidator::validateVersion() + $this->addEventTestListener(function (PreCreateEvent $event) use ($target_version): void { + /** @var \Drupal\auto_updates\ConsoleUpdateStage $stage */ + $stage = $event->stage; + $stage->setMetadata('packages', [ + 'production' => [ + 'drupal/core-recommended' => $target_version, + ], + ]); + }, PreCreateEvent::class); + + $expected_results = []; + if ($expected_validation_messages) { + $expected_results[] = $this->createVersionPolicyValidationResult($installed_version, $target_version, $expected_validation_messages); + } + + foreach ($cron_modes as $cron_mode) { + $this->config('auto_updates.settings') + ->set('unattended.level', $cron_mode) + ->set('allow_core_minor_updates', $allow_minor_updates) + ->save(); + + $stage = $this->container->get(ConsoleUpdateStage::class); + try { + $stage->create(); + // If we did not get an exception, ensure we didn't expect any results. + $this->assertEmpty($expected_results); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException($expected_results, $e); + } + finally { + $stage->destroy(TRUE); + } + } + } + + /** + * Data provider for testApi(). + * + * @return mixed[][] + * The test cases. + */ + public function providerApi(): array { + $metadata_dir = __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history'; + + return [ + 'valid target, dev snapshot installed' => [ + '9.8.0-dev', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '9.8.1'], + [ + $this->createVersionPolicyValidationResult('9.8.0-dev', '9.8.1', [ + t('Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'), + ]), + ], + ], + 'unsupported target, minor version upgrade' => [ + '9.7.1', + "$metadata_dir/drupal.9.8.2-unsupported_unpublished.xml", + ['drupal' => '9.8.1'], + [ + $this->createVersionPolicyValidationResult('9.7.1', '9.8.1', [ + t('Drupal cannot be automatically updated from 9.7.1 to 9.8.1 because automatic updates from one minor version to another are not supported.'), + ]), + ], + ], + 'unsupported target, major version upgrade' => [ + '8.9.1', + "$metadata_dir/drupal.9.8.2-unsupported_unpublished.xml", + ['drupal' => '9.8.1'], + [ + $this->createVersionPolicyValidationResult('8.9.1', '9.8.1', [ + t('Drupal cannot be automatically updated from 8.9.1 to 9.8.1 because automatic updates from one major version to another are not supported.'), + ]), + ], + ], + // The following cases are used to test every combination if a dev + // snapshot is installed. + 'insecure target, dev snapshot installed' => [ + '9.8.0-dev', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '9.8.0'], + [ + $this->createVersionPolicyValidationResult('9.8.0-dev', '9.8.0', [ + t('Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'), + t('Cannot update Drupal core to 9.8.0 because it is not in the list of installable releases.'), + ]), + ], + ], + 'downgrade major, dev snapshot installed' => [ + '9.8.0-dev', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '8.7.1'], + [ + $this->createVersionPolicyValidationResult('9.8.0-dev', '8.7.1', [ + t('Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'), + t('Update version 8.7.1 is lower than 9.8.0-dev, downgrading is not supported.'), + ]), + ], + ], + 'downgrade minor, dev snapshot installed' => [ + '9.8.0-dev', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '9.7.0'], + [ + $this->createVersionPolicyValidationResult('9.8.0-dev', '9.7.0', [ + t('Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'), + t('Update version 9.7.0 is lower than 9.8.0-dev, downgrading is not supported.'), + ]), + ], + ], + 'patch downgrade, dev snapshot installed' => [ + '9.8.1-dev', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '9.8.0'], + [ + $this->createVersionPolicyValidationResult('9.8.1-dev', '9.8.0', [ + t('Drupal cannot be automatically updated from the installed version, 9.8.1-dev, because automatic updates from a dev version to any other version are not supported.'), + t('Update version 9.8.0 is lower than 9.8.1-dev, downgrading is not supported.'), + ]), + ], + ], + // The following cases can only happen by explicitly supplying the + // update stage with an invalid target version. + 'downgrade' => [ + '9.8.1', + "$metadata_dir/drupal.9.8.2.xml", + ['drupal' => '9.8.0'], + [ + $this->createVersionPolicyValidationResult('9.8.1', '9.8.0', [ + t('Update version 9.8.0 is lower than 9.8.1, downgrading is not supported.'), + ]), + ], + ], + 'major version upgrade' => [ + '8.9.1', + "$metadata_dir/drupal.9.8.2.xml", + ['drupal' => '9.8.2'], + [ + $this->createVersionPolicyValidationResult('8.9.1', '9.8.2', [ + t('Drupal cannot be automatically updated from 8.9.1 to 9.8.2 because automatic updates from one major version to another are not supported.'), + ]), + ], + ], + 'unsupported target version' => [ + '9.8.0', + "$metadata_dir/drupal.9.8.2-unsupported_unpublished.xml", + ['drupal' => '9.8.1'], + [ + $this->createVersionPolicyValidationResult('9.8.0', '9.8.1', [ + t('Cannot update Drupal core to 9.8.1 because it is not in the list of installable releases.'), + ]), + ], + ], + // This case proves that an attended update to a normal non-security + // release is allowed regardless of how cron is configured. + 'attended update to normal release' => [ + '9.8.1', + "$metadata_dir/drupal.9.8.2.xml", + ['drupal' => '9.8.2'], + [], + ], + // These two cases prove that updating across minor versions of Drupal + // core is only allowed for attended updates when a specific configuration + // flag is set. + 'attended update to next minor not allowed' => [ + '9.7.9', + "$metadata_dir/drupal.9.8.2.xml", + ['drupal' => '9.8.2'], + [ + $this->createVersionPolicyValidationResult('9.7.9', '9.8.2', [ + t('Drupal cannot be automatically updated from 9.7.9 to 9.8.2 because automatic updates from one minor version to another are not supported.'), + ]), + ], + ], + 'attended update to next minor allowed' => [ + '9.7.9', + "$metadata_dir/drupal.9.8.2.xml", + ['drupal' => '9.8.2'], + [], + TRUE, + ], + // If attended updates across minor versions are allowed, it's okay to + // update from an unsupported minor version. + 'attended update from unsupported minor allowed' => [ + '9.7.9', + "$metadata_dir/drupal.9.8.1-security.xml", + ['drupal' => '9.8.1'], + [], + TRUE, + ], + ]; + } + + /** + * Tests validation of explicitly specified target versions. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string $release_metadata + * The path of the core release metadata to serve to the update system. + * @param string[] $project_versions + * The desired project versions that should be passed to the update stage. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param bool $allow_minor_updates + * (optional) Whether to allow attended updates across minor versions. + * Defaults to FALSE. + * + * @dataProvider providerApi + */ + public function testApi(string $installed_version, string $release_metadata, array $project_versions, array $expected_results, bool $allow_minor_updates = FALSE): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_metadata]); + + $this->config('auto_updates.settings') + ->set('allow_core_minor_updates', $allow_minor_updates) + ->save(); + + $stage = $this->container->get(UpdateStage::class); + + try { + $stage->begin($project_versions); + // Ensure that we did not, in fact, expect any errors. + $this->assertEmpty($expected_results); + // Reset the update stage for the next iteration of the loop. + $stage->destroy(); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException($expected_results, $e); + } + } + + /** + * Creates an expected validation result from the version policy validator. + * + * Results returned from VersionPolicyValidator are always summarized in the + * same way, so this method ensures that expected validation results are + * summarized accordingly. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $target_version + * The target version of Drupal core, or NULL if it's not known. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages that the result should contain. + * + * @return \Drupal\package_manager\ValidationResult + * A validation error object with the appropriate summary. + */ + private function createVersionPolicyValidationResult(string $installed_version, ?string $target_version, array $messages): ValidationResult { + if ($target_version) { + $summary = t('Updating from Drupal @installed_version to @target_version is not allowed.', [ + '@installed_version' => $installed_version, + '@target_version' => $target_version, + ]); + } + else { + $summary = t('Updating from Drupal @installed_version is not allowed.', [ + '@installed_version' => $installed_version, + ]); + } + return ValidationResult::createError($messages, $summary); + } + + /** + * Tests that an error is raised if there are no stored package versions. + * + * This is a contrived situation that should never happen in real life, but + * just in case it does, we need to be sure that it's an error condition. + */ + public function testNoStagedPackageVersions(): void { + // Remove the stored package versions from the update stage's metadata. + $listener = function (PreCreateEvent $event): void { + /** @var \Drupal\Tests\auto_updates\Kernel\TestUpdateStage $stage */ + $stage = $event->stage; + $stage->setMetadata('packages', [ + 'production' => [], + ]); + }; + $this->assertTargetVersionNotDiscoverable($listener); + } + + /** + * Tests that an error is raised if no core packages are installed. + * + * This is a contrived situation that should never happen in real life, but + * just in case it does, we need to be sure that it's an error condition. + */ + public function testNoCorePackagesInstalled(): void { + $listener = function (PreCreateEvent $event): void { + // We should have staged package versions. + /** @var \Drupal\auto_updates\UpdateStage $stage */ + $stage = $event->stage; + $this->assertNotEmpty($stage->getPackageVersions()); + // Remove all core packages in the active directory. + (new ActiveFixtureManipulator()) + ->removePackage('drupal/core-recommended') + ->removePackage('drupal/core') + ->removePackage('drupal/core-dev', TRUE) + ->commitChanges(); + }; + $this->assertTargetVersionNotDiscoverable($listener); + } + + /** + * Asserts that an error is raised if the target version of Drupal is unknown. + * + * @param \Closure $listener + * A pre-create event listener to run before all validators. This should put + * the test project and/or update stage into a state which will cause + * \Drupal\auto_updates\Validator\VersionPolicyValidator::getTargetVersion() + * to throw an exception because the target version of Drupal core is not + * known. + */ + private function assertTargetVersionNotDiscoverable(\Closure $listener): void { + $this->addEventTestListener($listener, PreCreateEvent::class); + + $this->expectException(StageException::class); + $this->expectExceptionMessage('The target version of Drupal core could not be determined.'); + $this->container->get(UpdateStage::class) + ->begin(['drupal' => '9.8.1']); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/StatusCheck/WindowsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/WindowsValidatorTest.php new file mode 100644 index 000000000000..daa7c4e43df4 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/StatusCheck/WindowsValidatorTest.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel\StatusCheck; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\auto_updates\CronUpdateRunner; +use Drupal\auto_updates\Validator\WindowsValidator; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * @covers \Drupal\auto_updates\Validator\WindowsValidator + * @group auto_updates + * @internal + */ +class WindowsValidatorTest extends AutoUpdatesKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates', 'user']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('auto_updates'); + } + + /** + * Data provider for ::testBackgroundUpdatesDisallowedOnWindows(). + * + * @return array[] + * The test cases. + */ + public function providerBackgroundUpdatesDisallowedOnWindows(): array { + return [ + 'updates enabled via web, user has access to update form' => [ + ['administer software updates'], + [ + 'method' => 'web', + 'level' => CronUpdateRunner::ALL, + ], + [ + ValidationResult::createError([ + t('Unattended updates are not supported on Windows. Use <a href="/admin/reports/updates/update">the update form</a> to update Drupal core.'), + ]), + ], + ], + 'updates enabled via web, user cannot access update form' => [ + [], + [ + 'method' => 'web', + 'level' => CronUpdateRunner::ALL, + ], + [ + ValidationResult::createError([ + t('Unattended updates are not supported on Windows.'), + ]), + ], + ], + 'updates enabled via console, user has access to update form' => [ + ['administer software updates'], + [ + 'method' => 'console', + 'level' => CronUpdateRunner::ALL, + ], + [], + ], + 'updates enabled via console, user cannot access update form' => [ + [], + [ + 'method' => 'console', + 'level' => CronUpdateRunner::ALL, + ], + [], + ], + 'updates disabled, user has access to update form' => [ + ['administer software updates'], + [ + 'method' => 'web', + 'level' => CronUpdateRunner::DISABLED, + ], + [], + ], + 'updates disabled, user cannot access update form' => [ + [], + [ + 'method' => 'web', + 'level' => CronUpdateRunner::DISABLED, + ], + [], + ], + ]; + } + + /** + * Tests that background updates are not allowed on Windows. + * + * @param array $user_permissions + * The permissions the current user should have, if any. + * @param array $unattended_update_settings + * The `auto_updates.settings:unattended` config values. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results on Windows. + * + * @dataProvider providerBackgroundUpdatesDisallowedOnWindows + */ + public function testBackgroundUpdatesDisallowedOnWindows(array $user_permissions, array $unattended_update_settings, array $expected_results): void { + if ($user_permissions) { + $this->setUpCurrentUser([], $user_permissions, FALSE); + } + + $this->config('auto_updates.settings') + ->set('unattended', $unattended_update_settings) + ->save(); + + $property = new \ReflectionProperty(WindowsValidator::class, 'os'); + $property->setValue(NULL, 'Windows'); + $this->assertCheckerResultsFromManager($expected_results, TRUE); + + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('auto_updates') + ->addLogger($logger); + // If any validation errors are expected, they should be logged if we try to + // run an unattended update. + $this->runConsoleUpdateStage(); + foreach ($expected_results as $result) { + foreach ($result->messages as $message) { + $this->assertExceptionLogged((string) $message, $logger); + } + } + + // If we're not on Windows, we should never get an error. + $property->setValue(NULL, 'Linux'); + $this->assertCheckerResultsFromManager([], TRUE); + + // If unattended updates are enabled, ensure that they will succeed. + if ($unattended_update_settings['level'] !== CronUpdateRunner::DISABLED) { + $logger->reset(); + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + $this->runConsoleUpdateStage(); + $this->assertFalse($logger->hasRecords(RfcLogLevel::ERROR)); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdateStageTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdateStageTest.php new file mode 100644 index 000000000000..af1a00291d01 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/UpdateStageTest.php @@ -0,0 +1,210 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\auto_updates\UpdateStage; +use Drupal\package_manager\Exception\ApplyFailedException; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager_bypass\LoggingCommitter; +use Drupal\Tests\user\Traits\UserCreationTrait; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; + +/** + * @coversDefaultClass \Drupal\auto_updates\UpdateStage + * @group auto_updates + * @internal + */ +class UpdateStageTest extends AutoUpdatesKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * Tests that correct versions are staged after calling ::begin(). + */ + public function testCorrectVersionsStaged(): void { + // Simulate that we're running Drupal 9.8.0 and a security update to 9.8.1 + // is available. + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml', + ]); + + // Create a user who will own the stage even after the container is rebuilt. + $user = $this->createUser([], NULL, TRUE, ['uid' => 2]); + $this->setCurrentUser($user); + + $id = $this->container->get(UpdateStage::class)->begin([ + 'drupal' => '9.8.1', + ]); + // Rebuild the container to ensure the package versions are persisted. + /** @var \Drupal\Core\DrupalKernel $kernel */ + $kernel = $this->container->get('kernel'); + $kernel->rebuildContainer(); + $this->container = $kernel->getContainer(); + // Keep using the user account we created. + $this->setCurrentUser($user); + + $stage = $this->container->get(UpdateStage::class); + + // Ensure that the target package versions are what we expect. + $expected_versions = [ + 'production' => [ + 'drupal/core-recommended' => '9.8.1', + ], + 'dev' => [ + 'drupal/core-dev' => '9.8.1', + ], + ]; + $this->assertSame($expected_versions, $stage->claim($id)->getPackageVersions()); + + // When we call UpdateStage::stage(), the stored project versions should be + // read from state and passed to Composer Stager's Stager service, in the + // form of a Composer command. This is done using package_manager_bypass's + // invocation recorder, rather than a regular mock, in order to test that + // the invocation recorder itself works. + // The production requirements are changed first, followed by the dev + // requirements. Then the installed packages are updated. This is tested + // functionally in Package Manager. + // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest + $expected_arguments = [ + [ + 'require', + '--no-update', + 'drupal/core-recommended:9.8.1', + ], + [ + 'require', + '--dev', + '--no-update', + 'drupal/core-dev:9.8.1', + ], + [ + 'update', + '--with-all-dependencies', + 'drupal/core-recommended:9.8.1', + 'drupal/core-dev:9.8.1', + ], + ]; + $stage->stage(); + + $actual_arguments = $this->container->get(StagerInterface::class) + ->getInvocationArguments(); + + $this->assertCount(count($expected_arguments), $actual_arguments); + foreach ($actual_arguments as $i => [$arguments]) { + $this->assertSame($expected_arguments[$i], $arguments); + } + } + + /** + * @covers ::begin + * + * @dataProvider providerInvalidProjectVersions + */ + public function testInvalidProjectVersions(array $project_versions): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Currently only updates to Drupal core are supported.'); + $this->container->get(UpdateStage::class)->begin($project_versions); + } + + /** + * Data provider for testInvalidProjectVersions(). + * + * @return mixed[][] + * The test cases. + */ + public function providerInvalidProjectVersions(): array { + return [ + 'only not drupal' => [['not_drupal' => '1.1.3']], + 'not drupal and drupal' => [['drupal' => '9.8.0', 'not_drupal' => '1.2.3']], + 'empty' => [[]], + ]; + } + + /** + * Data provider for testCommitException(). + * + * @return string[][] + * The test cases. + */ + public function providerCommitException(): array { + return [ + 'RuntimeException' => [ + \RuntimeException::class, + ApplyFailedException::class, + ], + 'InvalidArgumentException' => [ + InvalidArgumentException::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|null $expected_class + * The expected exception class. + * + * @dataProvider providerCommitException + */ + public function testCommitException(string $thrown_class, string $expected_class = NULL): void { + $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); + + $stage = $this->container->get(UpdateStage::class); + $stage->begin([ + 'drupal' => '9.8.1', + ]); + $stage->stage(); + $thrown_message = 'A very bad thing happened'; + // 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)) { + $thrown_message = $this->createComposeStagerMessage($thrown_message); + } + LoggingCommitter::setException(new $thrown_class($thrown_message, 123)); + $this->expectException($expected_class); + $expected_message = $expected_class === ApplyFailedException::class ? + "Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup." + : $thrown_message; + $this->expectExceptionMessage((string) $expected_message); + $this->expectExceptionCode(123); + $stage->apply(); + } + + /** + * Tests that setLogger is called on the update stage service. + */ + public function testLoggerIsSetByContainer(): void { + $stage_method_calls = $this->container->getDefinition(UpdateStage::class)->getMethodCalls(); + $this->assertSame('setLogger', $stage_method_calls[0][0]); + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/EmailNotificationsTestTrait.php b/core/modules/auto_updates/tests/src/Traits/EmailNotificationsTestTrait.php new file mode 100644 index 000000000000..6486e4d62b01 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/EmailNotificationsTestTrait.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\Core\Test\AssertMailTrait; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Contains helper methods for testing email sent by Automatic Updates. + * + * @internal + */ +trait EmailNotificationsTestTrait { + + use AssertMailTrait; + use UserCreationTrait; + + /** + * The people who should be emailed about successful or failed updates. + * + * The keys are the email addresses, and the values are the langcode they + * should be emailed in. + * + * @var string[] + * + * @see ::setUpEmailRecipients() + */ + protected $emailRecipients = []; + + /** + * Prepares the recipient list for emails related to Automatic Updates. + */ + protected function setUpEmailRecipients(): void { + // First, create a user whose preferred language is different from the + // default language, so we can be sure they're emailed in their preferred + // language; we also ensure that an email which doesn't correspond to a user + // account is emailed in the default language. + $default_language = $this->container->get('language_manager') + ->getDefaultLanguage() + ->getId(); + $this->assertNotSame('fr', $default_language); + + $account = $this->createUser([], NULL, FALSE, [ + 'preferred_langcode' => 'fr', + ]); + $this->emailRecipients['emissary@deep.space'] = $default_language; + $this->emailRecipients[$account->getEmail()] = $account->getPreferredLangcode(); + + $this->config('update.settings') + ->set('notification.emails', array_keys($this->emailRecipients)) + ->save(); + } + + /** + * Asserts that all recipients received a given email. + * + * @param string $expected_subject + * The subject line of the email that should have been sent. + * @param string $expected_body + * The beginning of the body text of the email that should have been sent. + * + * @see ::$emailRecipients + */ + protected function assertMessagesSent(string $expected_subject, string $expected_body): void { + $sent_messages = $this->getMails([ + 'subject' => $expected_subject, + ]); + $this->assertNotEmpty($sent_messages); + $this->assertCount(count($this->emailRecipients), $sent_messages); + + // Ensure the body is formatted the way the PHP mailer would do it. + $expected_message = [ + 'body' => [$expected_body], + ]; + $expected_message = $this->container->get('plugin.manager.mail') + ->createInstance('php_mail') + ->format($expected_message); + $expected_body = $expected_message['body']; + + foreach ($sent_messages as $sent_message) { + $email = $sent_message['to']; + $expected_langcode = $this->emailRecipients[$email]; + + $this->assertSame($expected_langcode, $sent_message['langcode']); + // The message, and every line in it, should have been sent in the + // expected language. + // @see auto_updates_test_mail_alter() + $this->assertArrayHasKey('line_langcodes', $sent_message); + $this->assertSame([$expected_langcode], $sent_message['line_langcodes']); + $this->assertStringStartsWith($expected_body, $sent_message['body']); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/TestSetUpTrait.php b/core/modules/auto_updates/tests/src/Traits/TestSetUpTrait.php new file mode 100644 index 000000000000..e19e45df092b --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/TestSetUpTrait.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Common functions to set up tests correctly. + */ +trait TestSetUpTrait { + + use FixtureUtilityTrait; + + /** + * {@inheritdoc} + */ + protected function installModulesFromClassProperty(ContainerInterface $container): void { + $container->get('module_installer')->install([ + 'package_manager_test_release_history', + 'package_manager_bypass', + ]); + $this->container = $container->get('kernel')->getContainer(); + + // To prevent tests from using the real codebase for Composer we must set + // the fixture path as early as possible. Automatic Updates will run + // readiness checks when the module is installed so this must be done before + // the parent class installs the modules needed for the test. + $this->useFixtureDirectoryAsActive(__DIR__ . '/../../../../package_manager/tests/fixtures/fake_site'); + + // To prevent tests from making real requests to the Internet, use fake + // release metadata that exposes a pretend Drupal 9.8.2 release. + $this->setReleaseMetadata(__DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml'); + + parent::installModulesFromClassProperty($container); + } + + /** + * Copies a fixture directory to a temporary directory. + * + * @param string $fixture_directory + * The fixture directory. + * + * @return string + * The temporary directory. + */ + protected function copyFixtureToTempDirectory(string $fixture_directory): string { + $temp_directory = $this->root . DIRECTORY_SEPARATOR . $this->siteDirectory . DIRECTORY_SEPARATOR . $this->randomMachineName(20); + static::copyFixtureFilesTo($fixture_directory, $temp_directory); + return $temp_directory; + } + + /** + * Sets a fixture directory to use as the active directory. + * + * @param string $fixture_directory + * The fixture directory. + */ + protected function useFixtureDirectoryAsActive(string $fixture_directory): void { + // Create a temporary directory from our fixture directory that will be + // unique for each test run. This will enable changing files in the + // directory and not affect other tests. + $active_dir = $this->copyFixtureToTempDirectory($fixture_directory); + $this->container->get(PathLocator::class) + ->setPaths($active_dir, $active_dir . '/vendor', '', NULL); + } + + /** + * Sets the release metadata file to use when fetching available updates. + * + * @todo Remove this function with use of the trait from the Update module in + * https://drupal.org/i/3348234. + * + * @param string $file + * The path of the XML metadata file to use. + */ + protected function setReleaseMetadata(string $file): void { + $this->assertFileIsReadable($file); + + $this->config('update.settings') + ->set('fetch.url', $this->baseUrl . '/test-release-history') + ->save(); + + [$project] = explode('.', basename($file, '.xml'), 2); + $xml_map = $this->config('update_test.settings')->get('xml_map') ?? []; + $xml_map[$project] = $file; + $this->config('update_test.settings') + ->set('xml_map', $xml_map) + ->save(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php new file mode 100644 index 000000000000..f00b2318f045 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\auto_updates\Validation\StatusChecker; +use Drupal\package_manager\ValidationResult; +use Drupal\system\SystemManager; +use Drupal\Tests\package_manager\Traits\ValidationTestTrait as PackageManagerValidationTestTrait; + +/** + * Common methods for testing validation. + * + * @internal + */ +trait ValidationTestTrait { + + use PackageManagerValidationTestTrait; + + /** + * Expected explanation text when status checkers return error messages. + * + * @var string + */ + protected static $errorsExplanation = 'Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.'; + + /** + * Expected explanation text when status checkers return warning messages. + * + * @var string + */ + protected static $warningsExplanation = 'Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.'; + + /** + * Creates a unique validation test result. + * + * @param int $severity + * The severity. Should be one of the SystemManager::REQUIREMENT_* + * constants. + * @param int $message_count + * (optional) The number of messages. Defaults to 1. + * + * @return \Drupal\package_manager\ValidationResult + * The validation test result. + */ + protected function createValidationResult(int $severity, int $message_count = 1): ValidationResult { + $this->assertNotEmpty($message_count); + $messages = []; + $random = $this->randomMachineName(64); + for ($i = 0; $i < $message_count; $i++) { + $messages[] = t("Message @i @random", ['@i' => $i, '@random' => $random]); + } + $summary = t('Summary @random', ['@random' => $random]); + switch ($severity) { + case SystemManager::REQUIREMENT_ERROR: + return ValidationResult::createError($messages, $summary); + + case SystemManager::REQUIREMENT_WARNING: + return ValidationResult::createWarning($messages, $summary); + + default: + throw new \InvalidArgumentException("$severity is an invalid value for \$severity; it must be SystemManager::REQUIREMENT_ERROR or SystemManager::REQUIREMENT_WARNING."); + } + } + + /** + * Gets the messages of a particular type from the manager. + * + * @param bool $call_run + * Whether to run the checkers. + * @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[]|null + * The messages of the type. + */ + protected function getResultsFromManager(bool $call_run = FALSE, ?int $severity = NULL): ?array { + $manager = $this->container->get(StatusChecker::class); + if ($call_run) { + $manager->run(); + } + return $manager->getResults($severity); + } + + /** + * Asserts expected validation results from the manager. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected results. + * @param bool $call_run + * (Optional) Whether to call ::run() on the manager. Defaults to FALSE. + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + */ + protected function assertCheckerResultsFromManager(array $expected_results, bool $call_run = FALSE, ?int $severity = NULL): void { + $actual_results = $this->getResultsFromManager($call_run, $severity); + $this->assertValidationResultsEqual($expected_results, $actual_results); + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php b/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php new file mode 100644 index 000000000000..a85fa18775d6 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Traits; + +/** + * Common methods for testing version policy rules. + * + * @internal + */ +trait VersionPolicyTestTrait { + + /** + * Tests that a policy rule returns a set of errors. + * + * @param object $rule + * The policy rule under test. + * @param string $installed_version + * The installed version of Drupal. + * @param string|null $target_version + * The target version of Drupal, or NULL if it's not known. + * @param string[] $expected_errors + * The expected error messages, if any. + * @param \Drupal\update\ProjectRelease[] $available_releases + * (optional) The available releases of Drupal core, keyed by version. + * Defaults to an empty array. + */ + protected function assertPolicyErrors(object $rule, string $installed_version, ?string $target_version, array $expected_errors, array $available_releases = []): void { + $rule->setStringTranslation($this->getStringTranslationStub()); + + $actual_errors = array_map('strval', $rule->validate($installed_version, $target_version, $available_releases)); + $this->assertSame($expected_errors, $actual_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php b/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php new file mode 100644 index 000000000000..81e4c939595a --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit; + +use Drupal\package_manager\LegacyVersionUtility; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\LegacyVersionUtility + * @group auto_updates + * @internal + */ +class LegacyVersionUtilityTest extends UnitTestCase { + + /** + * @covers ::convertToSemanticVersion + * + * @param string $version_number + * The version number to covert. + * @param string $expected + * The expected result. + * + * @dataProvider providerConvertToSemanticVersion + */ + public function testConvertToSemanticVersion(string $version_number, string $expected): void { + $this->assertSame($expected, LegacyVersionUtility::convertToSemanticVersion($version_number)); + } + + /** + * Data provider for testConvertToSemanticVersion() + * + * @return string[][] + * The test cases. + */ + public function providerConvertToSemanticVersion(): array { + return [ + '8.x-1.2' => ['8.x-1.2', '1.2.0'], + '8.x-1.2-alpha1' => ['8.x-1.2-alpha1', '1.2.0-alpha1'], + '1.2.0' => ['1.2.0', '1.2.0'], + '1.2.0-alpha1' => ['1.2.0-alpha1', '1.2.0-alpha1'], + ]; + } + + /** + * @covers ::convertToLegacyVersion + * + * @param string $version_number + * The version number to covert. + * @param string|null $expected + * The expected result. + * + * @dataProvider providerConvertToLegacyVersion + */ + public function testConvertToLegacyVersion(string $version_number, ?string $expected): void { + $this->assertSame($expected, LegacyVersionUtility::convertToLegacyVersion($version_number)); + } + + /** + * Data provider for testConvertToLegacyVersion() + * + * @return mixed[][] + * The test cases. + */ + public function providerConvertToLegacyVersion(): array { + return [ + '1.2.0' => ['1.2.0', '8.x-1.2'], + '1.2.0-alpha1' => ['1.2.0-alpha1', '8.x-1.2-alpha1'], + '8.x-1.2' => ['8.x-1.2', '8.x-1.2'], + '8.x-1.2-alpha1' => ['8.x-1.2-alpha1', '8.x-1.2-alpha1'], + '1.2.3' => ['1.2.3', NULL], + '1.2.3-alpha1' => ['1.2.3-alpha1', NULL], + ]; + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php new file mode 100644 index 000000000000..5a7c887496bd --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot + * @group auto_updates + * @internal + */ +class ForbidDevSnapshotTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testForbidDevSnapshot(). + * + * @return mixed[][] + * The test cases. + */ + public function providerForbidDevSnapshot(): array { + return [ + 'stable version installed' => [ + '9.8.0', + [], + ], + 'alpha version installed' => [ + '9.8.0-alpha3', + [], + ], + 'beta version installed' => [ + '9.8.0-beta7', + [], + ], + 'release candidate installed' => [ + '9.8.0-rc2', + [], + ], + 'dev snapshot installed' => [ + '9.8.0-dev', + ['Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'], + ], + ]; + } + + /** + * Tests that trying to update from a dev snapshot raises an error. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerForbidDevSnapshot + */ + public function testForbidDevSnapshot(string $installed_version, array $expected_errors): void { + $rule = new ForbidDevSnapshot(); + $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php new file mode 100644 index 000000000000..f9c572bab718 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade + * @group auto_updates + * @internal + */ +class ForbidDowngradeTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testDowngradeForbidden(). + * + * @return mixed[][] + * The test cases. + */ + public function providerDowngradeForbidden(): array { + return [ + 'unknown target version' => [ + '9.8.0', + NULL, + ['Update version is lower than 9.8.0, downgrading is not supported.'], + ], + 'same versions' => [ + '9.8.0', + '9.8.0', + [], + ], + 'newer target version' => [ + '9.8.0', + '9.8.2', + [], + ], + 'older target version' => [ + '9.8.2', + '9.8.0', + ['Update version 9.8.0 is lower than 9.8.2, downgrading is not supported.'], + ], + ]; + } + + /** + * Tests that downgrading always raises an error. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $target_version + * The target version of Drupal core, or NULL if not known. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerDowngradeForbidden + */ + public function testDowngradeForbidden(string $installed_version, ?string $target_version, array $expected_errors): void { + $rule = new ForbidDowngrade(); + $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php new file mode 100644 index 000000000000..6243c055fb24 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates + * @group auto_updates + * @internal + */ +class ForbidMinorUpdatesTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testMinorUpdateForbidden(). + * + * @return mixed[][] + * The test cases. + */ + public function providerMinorUpdateForbidden(): array { + return [ + 'same versions' => [ + '9.8.0', + '9.8.0', + [], + ], + 'target version newer in same minor' => [ + '9.8.0', + '9.8.2', + [], + ], + 'target version older in same minor' => [ + '9.8.2', + '9.8.0', + [], + ], + 'target version in older minor' => [ + '9.8.0', + '9.7.2', + ['Drupal cannot be automatically updated from 9.8.0 to 9.7.2 because automatic updates from one minor version to another are not supported during cron.'], + ], + + 'target version in newer minor' => [ + '9.8.0', + '9.9.2', + ['Drupal cannot be automatically updated from 9.8.0 to 9.9.2 because automatic updates from one minor version to another are not supported during cron.'], + ], + 'target version in older major' => [ + '9.8.0', + '8.8.0', + ['Drupal cannot be automatically updated from 9.8.0 to 8.8.0 because automatic updates from one minor version to another are not supported during cron.'], + ], + 'target version in newer major' => [ + '9.8.0', + '10.8.0', + ['Drupal cannot be automatically updated from 9.8.0 to 10.8.0 because automatic updates from one minor version to another are not supported during cron.'], + ], + 'target version in older major and minor' => [ + '9.8.0', + '8.9.9', + ['Drupal cannot be automatically updated from 9.8.0 to 8.9.9 because automatic updates from one minor version to another are not supported during cron.'], + ], + 'target version in newer major and minor' => [ + '9.8.0', + '10.9.2', + ['Drupal cannot be automatically updated from 9.8.0 to 10.9.2 because automatic updates from one minor version to another are not supported during cron.'], + ], + ]; + } + + /** + * Tests that trying to update across minor versions raises an error. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $target_version + * The target version of Drupal core, or NULL if not known. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerMinorUpdateForbidden + */ + public function testMinorUpdateForbidden(string $installed_version, ?string $target_version, array $expected_errors): void { + $rule = new ForbidMinorUpdates(); + $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php new file mode 100644 index 000000000000..9cbdb09182dd --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch + * @group auto_updates + * @internal + */ +class MajorVersionMatchTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testMajorVersionMatch(). + * + * @return mixed[][] + * The test cases. + */ + public function providerMajorVersionMatch(): array { + return [ + 'same versions' => [ + '9.8.0', + '9.8.0', + [], + ], + 'target version newer in same minor' => [ + '9.8.0', + '9.8.2', + [], + ], + 'target version in newer minor' => [ + '9.8.0', + '9.9.2', + [], + ], + 'target version older in same minor' => [ + '9.8.2', + '9.8.0', + [], + ], + 'target version in older minor' => [ + '9.8.0', + '9.7.2', + [], + ], + 'target version in newer major' => [ + '9.8.0', + '10.0.0', + ['Drupal cannot be automatically updated from 9.8.0 to 10.0.0 because automatic updates from one major version to another are not supported.'], + ], + 'target version in older major' => [ + '9.8.0', + '8.9.0', + ['Drupal cannot be automatically updated from 9.8.0 to 8.9.0 because automatic updates from one major version to another are not supported.'], + ], + ]; + } + + /** + * Tests that trying to update across major versions raises an error. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $target_version + * The target version of Drupal core, or NULL if not known. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerMajorVersionMatch + */ + public function testMajorVersionMatch(string $installed_version, ?string $target_version, array $expected_errors): void { + $rule = new MajorVersionMatch(); + $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php new file mode 100644 index 000000000000..7b125a1d49dd --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled + * @group auto_updates + * @internal + */ +class StableReleaseInstalledTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testStableReleaseInstalled(). + * + * @return mixed[][] + * The test cases. + */ + public function providerStableReleaseInstalled(): array { + return [ + 'stable version installed' => [ + '9.8.0', + [], + ], + 'alpha version installed' => [ + '9.8.0-alpha3', + ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha3, because it is not a stable version.'], + ], + 'beta version installed' => [ + '9.8.0-beta7', + ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta7, because it is not a stable version.'], + ], + 'release candidate installed' => [ + '9.8.0-rc2', + ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc2, because it is not a stable version.'], + ], + ]; + } + + /** + * Tests that trying to update from a non-stable release raises an error. + * + * @param string $installed_version + * The installed version of Drupal core. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerStableReleaseInstalled + */ + public function testStableReleaseInstalled(string $installed_version, array $expected_errors): void { + $rule = new StableReleaseInstalled(); + $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php new file mode 100644 index 000000000000..dee33ed01d56 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease; +use Drupal\update\ProjectRelease; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease + * @group auto_updates + * @internal + */ +class TargetSecurityReleaseTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testTargetSecurityRelease(). + * + * @return mixed[][] + * The test cases. + */ + public function providerTargetSecurityRelease(): array { + return [ + 'target security release' => [ + [ + '9.8.1' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-8-1-release', + 'version' => '9.8.1', + 'terms' => [ + 'Release type' => ['Security update'], + ], + ]), + ], + [], + ], + 'target non-security release' => [ + [ + '9.8.1' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-8-1-release', + 'version' => '9.8.1', + ]), + ], + ['Drupal cannot be automatically updated during cron from 9.8.0 to 9.8.1 because 9.8.1 is not a security release.'], + ], + ]; + } + + /** + * Tests that the target version must be a security release. + * + * @param \Drupal\update\ProjectRelease[] $available_releases + * The available releases of Drupal core, keyed by version. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerTargetSecurityRelease + */ + public function testTargetSecurityRelease(array $available_releases, array $expected_errors): void { + $rule = new TargetSecurityRelease(); + $this->assertPolicyErrors($rule, '9.8.0', '9.8.1', $expected_errors, $available_releases); + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php new file mode 100644 index 000000000000..7aa06dc8dda1 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php @@ -0,0 +1,201 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable; +use Drupal\update\ProjectRelease; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable + * @group auto_updates + * @internal + */ +class TargetVersionInstallableTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testTargetVersionInstallable(). + * + * @return mixed[][] + * The test cases. + */ + public function providerTargetVersionInstallable(): array { + return [ + 'no available releases' => [ + [TRUE, FALSE], + '9.8.1', + '9.8.2', + [], + ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'], + ], + 'unknown target' => [ + [TRUE, FALSE], + '9.8.1', + '9.8.2', + [ + '9.8.1' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-8-1-release', + 'version' => '9.8.1', + ]), + ], + ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'], + ], + 'valid target' => [ + [TRUE, FALSE], + '9.8.1', + '9.8.2', + [ + '9.8.2' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-8-2-release', + 'version' => '9.8.2', + ]), + ], + [], + ], + 'installed version and target version are the same' => [ + [TRUE, FALSE], + '9.8.0', + '9.8.0', + [], + ['Cannot update Drupal core to 9.8.0 because it is not in the list of installable releases.'], + ], + 'unknown patch update' => [ + [TRUE, FALSE], + '9.8.0', + '9.8.2', + [], + ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'], + ], + 'valid target version newer in same minor' => [ + [TRUE, FALSE], + '9.8.0', + '9.8.2', + [ + '9.8.2' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-8-2-release', + 'version' => '9.8.2', + ]), + ], + [], + ], + 'target version in newer minor, minor updates forbidden' => [ + [FALSE], + '9.8.0', + '9.9.2', + [], + ['Drupal cannot be automatically updated from 9.8.0 to 9.9.2 because automatic updates from one minor version to another are not supported.'], + ], + 'unknown target version in newer minor, minor updates allowed' => [ + [TRUE], + '9.8.0', + '9.9.2', + [], + ['Cannot update Drupal core to 9.9.2 because it is not in the list of installable releases.'], + ], + 'valid target version in newer minor, minor updates allowed' => [ + [TRUE], + '9.8.0', + '9.9.2', + [ + '9.9.2' => ProjectRelease::createFromArray([ + 'status' => 'published', + 'release_link' => 'http://example.com/drupal-9-9-2-release', + 'version' => '9.9.2', + ]), + ], + [], + ], + 'target version older in same minor' => [ + [TRUE, FALSE], + '9.8.2', + '9.8.0', + [], + ['Cannot update Drupal core to 9.8.0 because it is not in the list of installable releases.'], + ], + 'target version in older minor, minor updates forbidden' => [ + [FALSE], + '9.8.0', + '9.7.2', + [], + ['Drupal cannot be automatically updated from 9.8.0 to 9.7.2 because automatic updates from one minor version to another are not supported.'], + ], + 'target version in older minor, minor updates allowed' => [ + [TRUE], + '9.8.0', + '9.7.2', + [], + ['Cannot update Drupal core to 9.7.2 because it is not in the list of installable releases.'], + ], + // In practice, the message produced by the next four cases will be + // superseded by the MajorVersionMatch rule. + // @see \Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch + // @see \Drupal\auto_updates\Validator\VersionPolicyValidator::isRuleSuperseded() + 'target version in older major, minor updates forbidden' => [ + [FALSE], + '9.8.0', + '8.8.0', + [], + ['Drupal cannot be automatically updated from 9.8.0 to 8.8.0 because automatic updates from one minor version to another are not supported.'], + ], + 'target version in older major, minor updates allowed' => [ + [TRUE], + '9.8.0', + '8.8.0', + [], + ['Cannot update Drupal core to 8.8.0 because it is not in the list of installable releases.'], + ], + 'target version in newer major, minor updates forbidden' => [ + [FALSE], + '9.8.0', + '10.8.0', + [], + ['Drupal cannot be automatically updated from 9.8.0 to 10.8.0 because automatic updates from one minor version to another are not supported.'], + ], + 'target version in newer major, minor updates allowed' => [ + [TRUE], + '9.8.0', + '10.8.0', + [], + ['Cannot update Drupal core to 10.8.0 because it is not in the list of installable releases.'], + ], + ]; + } + + /** + * Tests that the target version must be a known, installable release. + * + * @param bool[] $minor_updates_allowed + * The values of the allow_core_minor_updates config flag. The rule will be + * tested separately with each value. + * @param string $installed_version + * The installed version of Drupal core. + * @param string $target_version + * The target version of Drupal core, or NULL if not known. + * @param \Drupal\update\ProjectRelease[] $available_releases + * The available releases of Drupal core, keyed by version. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerTargetVersionInstallable + */ + public function testTargetVersionInstallable(array $minor_updates_allowed, string $installed_version, string $target_version, array $available_releases, array $expected_errors): void { + foreach ($minor_updates_allowed as $value) { + $config_factory = $this->getConfigFactoryStub([ + 'auto_updates.settings' => [ + 'allow_core_minor_updates' => $value, + ], + ]); + $rule = new TargetVersionInstallable($config_factory); + $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors, $available_releases); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php new file mode 100644 index 000000000000..6f104a01fed9 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\auto_updates\Unit\VersionPolicy; + +use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable; +use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable + * @group auto_updates + * @internal + */ +class TargetVersionStableTest extends UnitTestCase { + + use VersionPolicyTestTrait; + + /** + * Data provider for testTargetVersionStable(). + * + * @return mixed[][] + * The test cases. + */ + public function providerTargetVersionStable(): array { + return [ + 'stable target version' => [ + '9.9.0', + [], + ], + 'dev target version' => [ + '9.9.0-dev', + ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-dev, because it is not a stable version.'], + ], + 'alpha target version' => [ + '9.9.0-alpha3', + ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-alpha3, because it is not a stable version.'], + ], + 'beta target version' => [ + '9.9.0-beta7', + ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-beta7, because it is not a stable version.'], + ], + 'release candidate target version' => [ + '9.9.0-rc2', + ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-rc2, because it is not a stable version.'], + ], + ]; + } + + /** + * Tests that trying to update to a non-stable version raises an error. + * + * @param string $target_version + * The target version of Drupal core. + * @param string[] $expected_errors + * The expected error messages, if any. + * + * @dataProvider providerTargetVersionStable + */ + public function testTargetVersionStable(string $target_version, array $expected_errors): void { + $rule = new TargetVersionStable(); + $this->assertPolicyErrors($rule, '9.8.0', $target_version, $expected_errors); + } + +} diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml b/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml new file mode 100644 index 000000000000..3caac67b43f1 --- /dev/null +++ b/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml @@ -0,0 +1,5 @@ +name: Automatic Updates Theme +type: theme +description: 'Empty theme for tests.' +package: Testing +base theme: false diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml new file mode 100644 index 000000000000..0f3e8b23f43c --- /dev/null +++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml @@ -0,0 +1,5 @@ +name: Automatic Updates Theme With Updates +type: theme +description: 'Empty theme with updates for tests.' +package: Testing +base theme: false diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install new file mode 100644 index 000000000000..4f41b44e8e7a --- /dev/null +++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install @@ -0,0 +1,8 @@ +<?php + +/** + * @file + * Blank .install file. + * + * @see \Drupal\Tests\auto_updates\Kernel\StatusCheck\StagedDatabaseUpdateValidatorTest + */ diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php new file mode 100644 index 000000000000..d3044886c4f9 --- /dev/null +++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php @@ -0,0 +1,8 @@ +<?php + +/** + * @file + * Blank .post_update file. + * + * @see \Drupal\Tests\auto_updates\Kernel\StatusCheck\StagedDatabaseUpdateValidatorTest + */ 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..03a1caa77571 --- /dev/null +++ b/core/modules/package_manager/config/install/package_manager.settings.yml @@ -0,0 +1,8 @@ +# The rsync file syncer is currently the only stable file syncer and +# should only be changed for development purposes. +file_syncer: rsync +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..a984bc1976ce --- /dev/null +++ b/core/modules/package_manager/config/schema/package_manager.schema.yml @@ -0,0 +1,30 @@ +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: + file_syncer: + type: string + label: 'Which file syncer to use, or NULL to auto-detect' + 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' 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..d38025e30b00 --- /dev/null +++ b/core/modules/package_manager/package_manager.api.php @@ -0,0 +1,291 @@ +<?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 include site-specific assets that aren't managed by + * Composer, such as 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. These four distinct operations + * -- create, require, apply, and destroy -- 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. But in most cases, + * custom code should 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..e7130aa8571b --- /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) { + $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 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..e97c60a72411 --- /dev/null +++ b/core/modules/package_manager/package_manager.module @@ -0,0 +1,112 @@ +<?php + +/** + * @file + * Contains hook implementations for Package Manager. + */ + +declare(strict_types = 1); + +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\package_manager\ComposerInspector; + +// cspell:ignore grasmash + +/** + * Implements hook_help(). + */ +function package_manager_help($route_name, RouteMatchInterface $route_match) { + 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. The path to the executable may be stored in config, or it will be automatically detected. To set the path to Composer, you can add the following line to settings.php:', ['@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('By default, Package Manager expects to be able to run <code>rsync</code> to copy files between the live site and the stage directory. It is strongly recommended to use <code>rsync</code>, because it is more reliable than the built-in PHP file syncer. 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 .= '<p>' . t('If <code>rsync</code> is available but Package Manager is configured to use the built-in PHP file syncer, you should also add the following to <code>settings.php</code>:') . '</p>'; + $output .= "<pre><code>\$config['package_manager.settings']['file_syncer'] = '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</code> Composer repository to be defined in your <code>composer.json</code> file, since Drupal.org is currently the only package repository that has support for TUF. To set this up, run the following commands (assuming your site is based on the <code>drupal/recommended-project</code> or <code>drupal/legacy-project</code> templates):') . '</p>'; + $output .= '<pre><code>'; + $output .= "composer config --unset repositories.0\n"; + $output .= "composer config repositories.drupal '{\"type\": \"composer\", \"url\": \"https://packages.drupal.org/8\", \"tuf\": true}'\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; + } +} 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..5a7bad7b78b1 --- /dev/null +++ b/core/modules/package_manager/package_manager.services.yml @@ -0,0 +1,178 @@ +services: + _defaults: + 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\FileSyncerFactory: + public: false + decorates: 'PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface' + 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' + Drupal\package_manager\NoSymlinksPointToADirectory: + public: false + decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\NoSymlinksPointToADirectoryInterface' + PhpTuf\ComposerStager\API\FileSyncer\Service\FileSyncerInterface: + factory: ['@PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface', 'create'] + 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: + tags: + - { name: event_subscriber } + Drupal\package_manager\EventSubscriber\UpdateDataSubscriber: + # @todo Autowire this service when https://drupal.org/i/3325557 lands. + arguments: + - '@update.manager' + autowire: false + tags: + - { name: event_subscriber } + Drupal\package_manager\EventSubscriber\ChangeLogger: + calls: + - [setLogger, ['@logger.channel.package_manager_change_log']] + tags: + - { name: event_subscriber } + Drupal\package_manager\ComposerInspector: + calls: + - [setLogger, ['@logger.channel.package_manager']] + + # Validators. + Drupal\package_manager\Validator\EnvironmentSupportValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\ComposerValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\DiskSpaceValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\PendingUpdatesValidator: + arguments: + - '%app.root%' + - '@update.post_update_registry' + # @todo Autowire this service when https://drupal.org/i/3325557 lands. + autowire: false + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\LockFileValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\WritableFileSystemValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\ComposerMinimumStabilityValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\MultisiteValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\SymlinkValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\DuplicateInfoFileValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\EnabledExtensionsValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\OverwriteExistingPackagesValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\AllowedScaffoldPackagesValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\StagedDBUpdateValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\TestSiteExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\VendorHardeningExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\SiteFilesExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\GitExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\UnknownPathExcluder: + tags: + - { name: event_subscriber } + calls: + - [setLogger, ['@logger.channel.package_manager']] + Drupal\package_manager\PathExcluder\SiteConfigurationExcluder: + arguments: + $sitePath: '%site.path%' + tags: + - { name: event_subscriber } + Drupal\package_manager\PathExcluder\NodeModulesExcluder: + tags: + - { name: event_subscriber } + Drupal\package_manager\PackageManagerUninstallValidator: + tags: + - { name: module_install.uninstall_validator } + arguments: + $eventDispatcher: '@event_dispatcher' + lazy: true + Drupal\package_manager\Validator\SettingsValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\RsyncValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\ComposerPluginsValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\ComposerPatchesValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\SupportedReleaseValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\StageNotInActiveValidator: + tags: + - { name: event_subscriber } + Drupal\package_manager\Validator\PhpExtensionsValidator: + tags: + - { name: event_subscriber } + # @todo Tag this service as an event subscriber in https://drupal.org/i/3358504, + # once packages.drupal.org supports TUF. + Drupal\package_manager\Validator\PhpTufValidator: + arguments: + $baseUrl: 'https://packages.drupal.org' + 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..2d8eac506fb0 --- /dev/null +++ b/core/modules/package_manager/src/ComposerInspector.php @@ -0,0 +1,496 @@ +<?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'; + + /** + * Constructs a ComposerInspector object. + * + * @param \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner + * The Composer runner service from Composer Stager. + * @param \PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface $composerIsAvailable + * The Composer Stager precondition to ensure that Composer is available. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service from Composer Stager. + */ + 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, $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. + switch ($key) { + case 'extra': + return '{}'; + + default: + // Otherwise, re-throw the exception. + throw $e; + } + } + $output = $this->processCallback->getOutput(); + return isset($output) ? trim($output) : $output; + } + + /** + * 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'], $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. + * + * @todo Remove this when https://github.com/composer/composer/pull/11340 and + * we bump our Composer requirement accordingly. + */ + 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}"], $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. + $this->runner->run($options, $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, $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('sha256', $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..59c9c8f4381c --- /dev/null +++ b/core/modules/package_manager/src/Event/CollectPathsToExcludeEvent.php @@ -0,0 +1,129 @@ +<?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, and 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 will be assumed to already be relative to the + * project root, and ignored as given. + * + * @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 + * The directory name to scan for. + * + * @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..a83057cf722a --- /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..b5db57288495 --- /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..ed92adb40b38 --- /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..5cef0946a79a --- /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..be26b1f23553 --- /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..d4510043538f --- /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, adds error validation result. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * (optional) The summary of error messages. Only required 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..a38478ab3410 --- /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..db57153c6ef8 --- /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 values are version constraints and keys + * are package names in the form `vendor/name`. 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..02b40099b992 --- /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..10834fb002bf --- /dev/null +++ b/core/modules/package_manager/src/Event/StatusCheckEvent.php @@ -0,0 +1,77 @@ +<?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. + * + * The event's stage will be set with the type of stage that will perform the + * operations. The stage may or may not be currently in use. + */ +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. Required if there is more than one + * message, optional otherwise. + */ + 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..f62d1f5badbd --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php @@ -0,0 +1,191 @@ +<?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 metadata key under which to store the installed packages at start. + * + * @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'; + + /** + * Constructs a ChangeLogger 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, + ) {} + + /** + * Records packages installed in the project root. + * + * We need to do this before the stage 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 stage 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 all Package Manager changes. + * + * @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 of 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..beb7463bc579 --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php @@ -0,0 +1,51 @@ +<?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 { + + /** + * Constructs an UpdateDataSubscriber object. + * + * @param \Drupal\update\UpdateManagerInterface $updateManager + * The update manager service. + */ + 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..7cf59d149a97 --- /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..e90883265fbf --- /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 an 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..fa665221bcea --- /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..4a0854225841 --- /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..ede028c14e13 --- /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 + * clearer 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..a1dc2608e931 --- /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..9e924bed44e2 --- /dev/null +++ b/core/modules/package_manager/src/ExecutableFinder.php @@ -0,0 +1,43 @@ +<?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 { + + /** + * Constructs an ExecutableFinder object. + * + * @param \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $decorated + * The decorated executable finder. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + 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..e59c06520f1b --- /dev/null +++ b/core/modules/package_manager/src/FailureMarker.php @@ -0,0 +1,169 @@ +<?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 afterwards. 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 { + + /** + * Constructs a FailureMarker object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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 $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/FileSyncerFactory.php b/core/modules/package_manager/src/FileSyncerFactory.php new file mode 100644 index 000000000000..3f2ffdb9cfa6 --- /dev/null +++ b/core/modules/package_manager/src/FileSyncerFactory.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Service\FileSyncerInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Service\PhpFileSyncerInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Service\RsyncFileSyncerInterface; + +/** + * A file syncer factory which creates a file syncer according to 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 FileSyncerFactory implements FileSyncerFactoryInterface { + + /** + * Constructs a FileSyncerFactory object. + * + * @param \PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface $decorated + * The decorated file syncer factory. + * @param \PhpTuf\ComposerStager\API\FileSyncer\Service\PhpFileSyncerInterface $phpFileSyncer + * The PHP file syncer service. + * @param \PhpTuf\ComposerStager\API\FileSyncer\Service\RsyncFileSyncerInterface $rsyncFileSyncer + * The rsync file syncer service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + private readonly FileSyncerFactoryInterface $decorated, + private readonly PhpFileSyncerInterface $phpFileSyncer, + private readonly RsyncFileSyncerInterface $rsyncFileSyncer, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public function create(): FileSyncerInterface { + $syncer = $this->configFactory->get('package_manager.settings') + ->get('file_syncer'); + + switch ($syncer) { + case 'rsync': + return $this->rsyncFileSyncer; + + case 'php': + return $this->phpFileSyncer; + + default: + return $this->decorated->create(); + } + } + +} diff --git a/core/modules/package_manager/src/ImmutablePathList.php b/core/modules/package_manager/src/ImmutablePathList.php new file mode 100644 index 000000000000..dc61e744f14c --- /dev/null +++ b/core/modules/package_manager/src/ImmutablePathList.php @@ -0,0 +1,41 @@ +<?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 { + + /** + * Constructs an ImmutablePathList object. + * + * @param \PhpTuf\ComposerStager\API\Path\Value\PathListInterface $decorated + * The decorated path list. + */ + 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..ec5b1de935ea --- /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$/', \RecursiveRegexIterator::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..499c73120f0f --- /dev/null +++ b/core/modules/package_manager/src/InstalledPackagesList.php @@ -0,0 +1,184 @@ +<?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 { + + /** + * The statically cached names of the Drupal core packages. + * + * @var string[] + */ + private static ?array $corePackages = NULL; + + /** + * {@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..7523cda6b55e --- /dev/null +++ b/core/modules/package_manager/src/LegacyVersionUtility.php @@ -0,0 +1,86 @@ +<?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; + } + else { + return $version; + } + } + + /** + * Converts a version number to a legacy version if needed and possible. + * + * @param string $version_string + * The version number. + * + * @return string + * 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/NoSymlinksPointToADirectory.php b/core/modules/package_manager/src/NoSymlinksPointToADirectory.php new file mode 100644 index 000000000000..e05600e4ec38 --- /dev/null +++ b/core/modules/package_manager/src/NoSymlinksPointToADirectory.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\NoSymlinksPointToADirectoryInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; + +/** + * Checks if the code base contains any symlinks that point to a directory. + * + * Since rsync supports copying symlinks to directories, but Composer Stager's + * PHP file syncer doesn't, this precondition is automatically fulfilled if + * Package Manager is *explicitly* configured to use rsync. + * + * @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 NoSymlinksPointToADirectory implements NoSymlinksPointToADirectoryInterface { + + use StringTranslationTrait; + + /** + * Constructs a NoSymlinksPointToADirectory object. + * + * @param \PhpTuf\ComposerStager\API\Precondition\Service\NoSymlinksPointToADirectoryInterface $decorated + * The decorated precondition. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + */ + public function __construct( + private readonly NoSymlinksPointToADirectoryInterface $decorated, + private readonly ConfigFactoryInterface $configFactory + ) {} + + /** + * {@inheritdoc} + */ + public function getName(): TranslatableInterface { + return $this->decorated->getName(); + } + + /** + * {@inheritdoc} + */ + public function getDescription(): TranslatableInterface { + return $this->decorated->getDescription(); + } + + /** + * {@inheritdoc} + */ + public function getStatusMessage(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT,): TranslatableInterface { + if ($this->isUsingRsync()) { + return $this->t('Symlinks to directories are supported by the rsync file syncer.'); + } + return $this->decorated->getStatusMessage($activeDir, $stagingDir, $exclusions); + } + + /** + * {@inheritdoc} + */ + public function isFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT,): bool { + return $this->isUsingRsync() || $this->decorated->isFulfilled($activeDir, $stagingDir, $exclusions); + } + + /** + * {@inheritdoc} + */ + public function assertIsFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT,): void { + if ($this->isUsingRsync()) { + return; + } + $this->decorated->assertIsFulfilled($activeDir, $stagingDir, $exclusions); + } + + /** + * Indicates if Package Manager is explicitly configured to use rsync. + * + * @return bool + * TRUE if Package Manager is explicitly configured to use the rsync file + * syncer, FALSE otherwise. + */ + private function isUsingRsync(): bool { + $syncer = $this->configFactory->get('package_manager.settings') + ->get('file_syncer'); + + return $syncer === 'rsync'; + } + + /** + * {@inheritdoc} + */ + public function getLeaves(): array { + return [$this]; + } + +} diff --git a/core/modules/package_manager/src/PackageManagerServiceProvider.php b/core/modules/package_manager/src/PackageManagerServiceProvider.php new file mode 100644 index 000000000000..d861cafbf134 --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerServiceProvider.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; + +/** + * 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 extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // Use an interface that we know exists to determine the absolute path where + // Composer Stager is installed. + $mirror = new \ReflectionClass(BeginnerInterface::class); + $path = dirname($mirror->getFileName(), 3); + + // 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); + $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..1a83088927b7 --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerUninstallValidator.php @@ -0,0 +1,91 @@ +<?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; + + /** + * Constructs a new PackageManagerUninstallValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner + * The beginner service. + * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager + * The stager service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer + * The committer service. + * @param \Drupal\Core\Queue\QueueFactory $queueFactory + * The queue factory service. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $sharedTempStoreFactory + * The shared temp store factory service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \Drupal\package_manager\FailureMarker $failureMarker + * The failure marker service. + */ + 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 {}; + if ($stage->isAvailable() || !$stage->isApplying()) { + return []; + } + if ($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..08f952189bc0 --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerUpdateProcessor.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +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 { + + /** + * Constructs an PackageManagerUpdateProcessor object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\Core\Queue\QueueFactory $queue_factory + * The queue factory. + * @param \Drupal\update\UpdateFetcherInterface $update_fetcher + * The update fetcher service. + * @param \Drupal\Core\State\StateInterface $state_store + * The state service. + * @param \Drupal\Core\PrivateKey $private_key + * The private key factory service. + * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory + * The key/value factory. + * @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory + * The expirable key/value factory. + */ + 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) { + parent::__construct($config_factory, $queue_factory, $update_fetcher, $state_store, $private_key, $key_value_factory, $key_value_expirable_factory); + $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 it's 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..7bc54d435052 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/GitExcluder.php @@ -0,0 +1,88 @@ +<?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 { + + /** + * Constructs a GitExcluder object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + */ + 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. + * + * @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..a954a0c10326 --- /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..1745d92f4e48 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php @@ -0,0 +1,154 @@ +<?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 { + + /** + * Constructs an SiteConfigurationExcluder. + * + * @param string $sitePath + * The current site path, relative to the Drupal root. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + */ + 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. + $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 world-writable. + * + * This is done to allow the core scaffold plugin to make changes in + * `sites/default`, if necessary, without breaking if `sites/default` is not + * writable (this can happen because rsync preserves directory permissions, + * and Drupal will try to harden the site directory's permissions as much as + * possible). We specifically exclude the `default.settings.php` and + * `default.services.yml` files from Package Manager operations, so we want to + * allow the scaffold plugin to make whatever changes it wants to 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, 0777)) { + 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..949e01686995 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php @@ -0,0 +1,73 @@ +<?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 public and private 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 { + + /** + * Constructs a SiteFilesExcluder object. + * + * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager + * The stream wrapper manager service. + * @param \Symfony\Component\Filesystem\Filesystem $fileSystem + * The Symfony file system service. + */ + public function __construct( + private readonly StreamWrapperManagerInterface $streamWrapperManager, + private readonly Filesystem $fileSystem + ) {} + + /** + * {@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 public and private files. 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 (['public', 'private'] 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..f21f74fde865 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php @@ -0,0 +1,75 @@ +<?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 { + + /** + * Constructs a SqliteDatabaseExcluder object. + * + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + 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 we should exclude it. + if ($this->database->driver() === 'sqlite') { + $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 of 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..59b9b2edd9ff --- /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..1018a527f414 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/UnknownPathExcluder.php @@ -0,0 +1,189 @@ +<?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 Psr\Log\NullLogger; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Excludes unknown paths from stage operations. + * + * Any paths in the root directory of the project that are 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 web root and 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; + + /** + * Constructs a UnknownPathExcluder object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + */ + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + private readonly ConfigFactoryInterface $configFactory, + ) { + $this->setLogger(new NullLogger()); + } + + /** + * {@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. + // Note: the call to ComposerInspector::getConfig() would + // also have triggered this, 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, '/'); + } + + // 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..2d46271b2096 --- /dev/null +++ b/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php @@ -0,0 +1,55 @@ +<?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 { + + /** + * Constructs a VendorHardeningExcluder object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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 . '/web.config', + $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..9b73513e2a0c --- /dev/null +++ b/core/modules/package_manager/src/PathLocator.php @@ -0,0 +1,106 @@ +<?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 { + + /** + * Constructs a PathLocator object. + * + * @param string $appRoot + * The absolute path of the running Drupal code base. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + */ + 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..b129563e2802 --- /dev/null +++ b/core/modules/package_manager/src/Plugin/QueueWorker/Cleaner.php @@ -0,0 +1,62 @@ +<?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 { + + /** + * Constructs a new Cleaner instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin ID. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + */ + 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) { + assert(is_string($dir)); + + if (file_exists($dir)) { + $this->fileSystem->deleteRecursive($dir, function (string $path): void { + $this->fileSystem->chmod($path, 0777); + }); + } + } + +} diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php new file mode 100644 index 000000000000..3e605046c2c2 --- /dev/null +++ b/core/modules/package_manager/src/ProcessFactory.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; +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 { + + /** + * Constructs a ProcessFactory object. + * + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface $decorated + * The decorated process factory service. + */ + public function __construct( + private readonly FileSystemInterface $fileSystem, + private readonly ConfigFactoryInterface $configFactory, + private readonly ProcessFactoryInterface $decorated, + ) {} + + /** + * Returns the value of an environment variable. + * + * @param string $variable + * The name of the variable. + * + * @return mixed + * The value of the variable. + */ + private function getEnv(string $variable) { + if (function_exists('apache_getenv')) { + return apache_getenv($variable); + } + return getenv($variable); + } + + /** + * {@inheritdoc} + */ + public function create(array $command): ProcessInterface { + $process = $this->decorated->create($command); + + $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() . ':' . $this->getEnv('PATH'); + return $process->setEnv($env); + } + + /** + * 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 strpos($file, 'composer') === 0; + } + +} diff --git a/core/modules/package_manager/src/ProcessOutputCallback.php b/core/modules/package_manager/src/ProcessOutputCallback.php new file mode 100644 index 000000000000..38511af4ac7b --- /dev/null +++ b/core/modules/package_manager/src/ProcessOutputCallback.php @@ -0,0 +1,112 @@ +<?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 string + */ + private string $outBuffer = ''; + + /** + * The error buffer. + * + * @var string + */ + private string $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 string|null + * The output or NULL if there is none. + */ + public function getOutput(): ?string { + $error_output = $this->getErrorOutput(); + if ($error_output) { + $this->logger->warning($error_output); + } + return trim($this->outBuffer) !== '' ? $this->outBuffer : NULL; + } + + /** + * 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 !== NULL) { + return json_decode($output, TRUE, flags: JSON_THROW_ON_ERROR); + } + return NULL; + } + + /** + * Gets the error output. + * + * @return string|null + * The error output or NULL if there isn't any. + */ + public function getErrorOutput(): ?string { + return trim($this->errorBuffer) !== '' ? $this->errorBuffer : NULL; + } + + /** + * Resets buffers. + * + * @return self + */ + public function reset(): self { + $this->errorBuffer = ''; + $this->outBuffer = ''; + 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..4750704be9f7 --- /dev/null +++ b/core/modules/package_manager/src/ProjectInfo.php @@ -0,0 +1,236 @@ +<?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 { + + /** + * Constructs a ProjectInfo object. + * + * @param string $name + * The project name. + */ + 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/ProxyClass/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php new file mode 100644 index 000000000000..73cd935eda33 --- /dev/null +++ b/core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php @@ -0,0 +1,88 @@ +<?php +// phpcs:ignoreFile + +/** + * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\package_manager\PackageManagerUninstallValidator' "modules/contrib/auto_updates/package_manager/src". + */ + +namespace Drupal\package_manager\ProxyClass { + + /** + * Provides a proxy class for \Drupal\package_manager\PackageManagerUninstallValidator. + * + * @see \Drupal\Component\ProxyBuilder + */ + class PackageManagerUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface + { + + use \Drupal\Core\DependencyInjection\DependencySerializationTrait; + + /** + * The id of the original proxied service. + * + * @var string + */ + protected $drupalProxyOriginalServiceId; + + /** + * The real proxied service, after it was lazy loaded. + * + * @var \Drupal\package_manager\PackageManagerUninstallValidator + */ + protected $service; + + /** + * The service container. + * + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + protected $container; + + /** + * Constructs a ProxyClass Drupal proxy object. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The container. + * @param string $drupal_proxy_original_service_id + * The service ID of the original service. + */ + public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id) + { + $this->container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function validate($module) + { + return $this->lazyLoadItself()->validate($module); + } + + /** + * {@inheritdoc} + */ + public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation) + { + return $this->lazyLoadItself()->setStringTranslation($translation); + } + + } + +} diff --git a/core/modules/package_manager/src/StageBase.php b/core/modules/package_manager/src/StageBase.php new file mode 100644 index 000000000000..8c7809ad82d9 --- /dev/null +++ b/core/modules/package_manager/src/StageBase.php @@ -0,0 +1,875 @@ +<?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 Psr\Log\NullLogger; +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; + + /** + * Constructs a new Stage object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner + * The beginner service. + * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager + * The stager service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer + * The committer service. + * @param \Drupal\Core\Queue\QueueFactory $queueFactory + * The queue factory. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory + * The shared tempstore factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \Drupal\package_manager\FailureMarker $failureMarker + * The failure marker service. + */ + 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'); + $this->setLogger(new NullLogger()); + } + + /** + * 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 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'], $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 $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. + 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..d4a1799b3e7f --- /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 $event_dispatcher + * (optional) The event dispatcher service. + * @param \Drupal\package_manager\PathLocator $path_locator + * (optional) The path locator service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $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..98e5eac96d71 --- /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..c0557eec9798 --- /dev/null +++ b/core/modules/package_manager/src/TranslatableStringFactory.php @@ -0,0 +1,64 @@ +<?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 { + + /** + * Constructs a TranslatableStringFactory object. + * + * @param \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $decorated + * The decorated translatable factory service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation service. + */ + 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..3292a4a9eca4 --- /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 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 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..4f2ca15c6a55 --- /dev/null +++ b/core/modules/package_manager/src/Validator/AllowedScaffoldPackagesValidator.php @@ -0,0 +1,80 @@ +<?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; + + /** + * Constructs a AllowedScaffoldPackagesValidator 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, + ) {} + + /** + * 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..85f90769a57b --- /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 helper methods for base requirement validators. + * + * This trait should only be used 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..c358744b6d1c --- /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 and we should 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..41ebbf6e2ef5 --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerMinimumStabilityValidator.php @@ -0,0 +1,90 @@ +<?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 (about to be) installed packages 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; + + /** + * Constructs a ComposerMinimumStabilityValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $inspector + * The Composer inspector service. + */ + 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 @code constraint@stability @endcode. A stability flag + // allow 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..e99124c34da1 --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php @@ -0,0 +1,192 @@ +<?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'; + + /** + * Constructs a ComposerPatchesValidator object. + * + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + * @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 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..ab07efaf46fa --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php @@ -0,0 +1,241 @@ +<?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): + * @code config.allowed-plugins = true @endcode is forbidden. + * - Installed Composer plugins that are not allowed (in composer.json's + * @code config.allowed-plugins @endcode) 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 + * @code package_manager.settings @endcode configuration's + * @code additional_trusted_composer_plugins @endcode 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 = [ + // cSpell:disable + // @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' => '*', + // cSpell:enable + ]; + + /** + * 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 = [ + // cSpell:disable + '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', + // cSpell:enable + PhpTufValidator::PLUGIN_NAME => '^1', + ]; + + /** + * The additional trusted Composer plugin package names. + * + * Note: these are normalized package names. + * + * @var string[] + * Keys are package names, values are version constraints. + */ + private array $additionalTrustedComposerPlugins; + + /** + * Constructs a new ComposerPluginsValidator. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\package_manager\ComposerInspector $inspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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') + ), + // For now, additional_trusted_composer_plugins cannot specify a version + // constraint. + '*' + ); + } + + /** + * 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..15427a54a427 --- /dev/null +++ b/core/modules/package_manager/src/Validator/ComposerValidator.php @@ -0,0 +1,156 @@ +<?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; + + /** + * Constructs a ComposerExecutableValidator object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + */ + 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.', [ + ':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..b3330f22cee1 --- /dev/null +++ b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php @@ -0,0 +1,153 @@ +<?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; + + /** + * Constructs a DiskSpaceValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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..350d988955e2 --- /dev/null +++ b/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php @@ -0,0 +1,120 @@ +<?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; + + /** + * Constructs a DuplicateInfoFileValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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..de1c7b703959 --- /dev/null +++ b/core/modules/package_manager/src/Validator/EnabledExtensionsValidator.php @@ -0,0 +1,88 @@ +<?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; + + /** + * Constructs an EnabledExtensionsValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\Core\Extension\ThemeHandlerInterface $themeHandler + * The theme handler service. + */ + 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..86ded72818ef --- /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..819f690742fa --- /dev/null +++ b/core/modules/package_manager/src/Validator/LockFileValidator.php @@ -0,0 +1,183 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\State\StateInterface; +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 state key under which to store the hash of the active lock file. + * + * @var string + */ + private const STATE_KEY = 'package_manager.lock_hash'; + + /** + * Constructs a LockFileValidator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + public function __construct( + private readonly StateInterface $state, + private readonly PathLocator $pathLocator + ) {} + + /** + * Returns the SHA-256 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) { + try { + return hash_file('sha256', $path); + } + catch (\Throwable) { + return FALSE; + } + } + + /** + * Stores the SHA-256 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->state->set(static::STATE_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->state->get(static::STATE_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 && $active_lock_file_stored_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->state->delete(static::STATE_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..22b7677adc0d --- /dev/null +++ b/core/modules/package_manager/src/Validator/MultisiteValidator.php @@ -0,0 +1,69 @@ +<?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; + + /** + * Constructs a new MultisiteValidator. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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..974498fd3634 --- /dev/null +++ b/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php @@ -0,0 +1,88 @@ +<?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. + * + * @link https://getcomposer.org/doc/04-schema.md#type + */ +final class OverwriteExistingPackagesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a OverwriteExistingPackagesValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + */ + 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..26eb7b950680 --- /dev/null +++ b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php @@ -0,0 +1,82 @@ +<?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; + + /** + * Constructs an PendingUpdatesValidator object. + * + * @param string $appRoot + * The Drupal root. + * @param \Drupal\Core\Update\UpdateRegistry $updateRegistry + * The update registry service. + */ + 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 schema updates to install. 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..afbcb46805c1 --- /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..1222474017dd --- /dev/null +++ b/core/modules/package_manager/src/Validator/PhpTufValidator.php @@ -0,0 +1,184 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Extension\ModuleHandlerInterface; +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 hosted at + * packages.drupal.org (since that's currently the only server that supports + * TUF), and that those repositories have TUF support explicitly enabled. + * + * Note that this validator is currently not active, because the service + * definition is not tagged as an event subscriber. This will be changed in + * https://drupal.org/i/3358504, once TUF support is rolled out on + * packages.drupal.org. + * + * @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'; + + /** + * Constructs a PhpTufValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + * @param string $baseUrl + * The base URL of the repository, or repositories, defined in + * `composer.json`that must be protected by TUF. + */ + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + private readonly ModuleHandlerInterface $moduleHandler, + private readonly string $baseUrl, + ) {} + + /** + * {@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 = []; + + 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; + } + + // Get the defined repositories that live at the base URL, and confirm that + // they have all opted into TUF protection. + $repositories = array_filter( + Json::decode($this->composerInspector->getConfig('repositories', $dir)), + fn (array $r): bool => str_starts_with($r['url'], $this->baseUrl) + ); + foreach ($repositories as $repository) { + if (empty($repository['tuf'])) { + $messages[] = $this->t('TUF is not enabled for the @url repository.', [ + '@url' => $repository['url'], + ]); + } + } + + // There must be at least one repository using the base URL. + if (empty($repositories)) { + $message = $this->t('The <code>@url</code> Composer repository must be defined in <code>composer.json</code>.', [ + '@url' => $this->baseUrl, + ]); + 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; + } + +} 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..315ea177312d --- /dev/null +++ b/core/modules/package_manager/src/Validator/RsyncValidator.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\Config\ConfigFactoryInterface; +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, if it is the configured file syncer. + * + * @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; + + /** + * Constructs an RsyncValidator object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + * @param \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $executableFinder + * The executable finder service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler service. + */ + public function __construct( + private readonly ConfigFactoryInterface $configFactory, + private readonly ExecutableFinderInterface $executableFinder, + private readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Checks that rsync is being used, if it's 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 ($this->moduleHandler->moduleExists('help')) { + $help_url = Url::fromRoute('help.page') + ->setRouteParameter('name', 'package_manager') + ->setOption('fragment', 'package-manager-faq-rsync') + ->toString(); + } + + $configured_syncer = $this->configFactory->get('package_manager.settings') + ->get('file_syncer'); + + // If the PHP file syncer is selected, warn that we don't recommend it. + if ($configured_syncer === 'php') { + // Only status checks support warnings. + if ($event instanceof StatusCheckEvent) { + $message = $this->t('You are currently using the PHP file syncer, which has known problems and is not stable. It is strongly recommended to switch back to the default <em>rsync</em> file syncer instead.'); + + if (!$rsync_found) { + $message = $this->t('@message <code>rsync</code> was not found on your system.', ['@message' => $message]); + } + if (isset($help_url)) { + $message = $this->t('@message See the <a href=":url">Package Manager help</a> for more information on how to resolve this.', [ + ':url' => $help_url, + '@message' => $message, + ]); + } + $event->addWarning([$message]); + } + // If we're not doing a status check, well, I guess we'll be using the + // PHP file syncer. There's nothing else to do. + return; + } + + if ($rsync_found === FALSE) { + $message = $this->t('<code>rsync</code> is not available.'); + + if (isset($help_url)) { + $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..9733d5f788f6 --- /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..13a9ec3b02fb --- /dev/null +++ b/core/modules/package_manager/src/Validator/StageNotInActiveValidator.php @@ -0,0 +1,60 @@ +<?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; + + /** + * Constructs a new StageNotInActiveValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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..60c6dbea82ff --- /dev/null +++ b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php @@ -0,0 +1,210 @@ +<?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; + + /** + * Constructs a StagedDBUpdateValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \Drupal\Core\Extension\ModuleExtensionList $moduleList + * The module list service. + * @param \Drupal\Core\Extension\ThemeExtensionList $themeList + * The theme list service. + */ + 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. + * + * @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. + */ + protected 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..176da4ed8873 --- /dev/null +++ b/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php @@ -0,0 +1,142 @@ +<?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; + + /** + * Constructs a SupportedReleaseValidator 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 + ) {} + + /** + * 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) { + $summary = $this->formatPlural( + count($unknown_packages), + 'Cannot update because the following new or updated Drupal package does not have project information.', + 'Cannot update because the following new or updated Drupal packages do not have project information.', + ); + $event->addError($unknown_packages, $summary); + } + } + + /** + * {@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..29ce772ec6fe --- /dev/null +++ b/core/modules/package_manager/src/Validator/SymlinkValidator.php @@ -0,0 +1,91 @@ +<?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; + + /** + * Constructs a SymlinkValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + * @param \PhpTuf\ComposerStager\API\Precondition\Service\NoUnsupportedLinksExistInterface $precondition + * The Composer Stager precondition that this validator wraps. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory + * The path factory service. + * @param \PhpTuf\ComposerStager\API\Path\Factory\PathListFactoryInterface $pathListFactory + * The path list factory service. + */ + 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..d5701757c92b --- /dev/null +++ b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php @@ -0,0 +1,98 @@ +<?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; + + /** + * Constructs a WritableFileSystemValidator object. + * + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + 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..83f1283d14e3 --- /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..4c3439376c39 --- /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..c67ca797d17e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.json @@ -0,0 +1,43 @@ +{ + "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": "./" + }, + "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..0345349f42eb --- /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": "17cd7e695939dc97e22d10fa4efbe10b", + "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", + "[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]/web.config": "assets/scaffold/files/web.config", + "[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/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..41761052d2c0 --- /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", + "[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]/web.config": "assets/scaffold/files/web.config", + "[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..3fe2cebab0bc --- /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", + "[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]/web.config": "assets/scaffold/files/web.config", + "[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..450d24b12b7e --- /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", + "[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]/web.config": "assets/scaffold/files/web.config", + "[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..450d24b12b7e --- /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", + "[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]/web.config": "assets/scaffold/files/web.config", + "[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..87b67eb11173 --- /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..00ea699db1f1 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php @@ -0,0 +1,615 @@ +<?php + +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 or not the package is a development requirement. + * @param bool $allow_plugins + * Whether or not 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 or not the package is a development requirement. + * @param bool $allow_plugins + * Whether or not 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 or not 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 or not 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 or not 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 { + public string $stdout = ''; + public string $stderr = ''; + + /** + * {@inheritdoc} + */ + public function __invoke(OutputTypeEnum $type, string $buffer): void { + if ($type === OutputTypeEnum::OUT) { + $this->stdout .= $buffer; + return; + } + elseif ($type === OutputTypeEnum::ERR) { + $this->stderr .= $buffer; + return; + } + } + + }; + /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */ + $runner = \Drupal::service(ComposerProcessRunnerInterface::class); + $command_options[] = "--working-dir={$this->dir}"; + $runner->run($command_options, $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..3066e3b724a9 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php @@ -0,0 +1,41 @@ +<?php + +namespace Drupal\fixture_manipulator; + +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): ProcessInterface { + $process = $this->decorated->create($command); + + $env = $process->getEnv(); + $env['COMPOSER_MIRROR_PATH_REPOS'] = '1'; + return $process->setEnv($env); + } + +} 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..d93bd0ad162c --- /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() { + 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..e7886b0be75d --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php @@ -0,0 +1,33 @@ +<?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 \Throwable|null $exception + * The exception to throw, or NULL to delete a stored exception. + */ + public static function setException(?\Throwable $exception): void { + \Drupal::state()->set(static::class . '-exception', $exception); + } + + /** + * Throws the exception if set. + */ + private function throwExceptionIfSet(): void { + if ($exception = $this->state->get(static::class . '-exception')) { + throw $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..a14543bedcee --- /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..41bda89e9743 --- /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 an 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..d0aa43e451fa --- /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..5a9d1517f20b --- /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..30eaccb7eded --- /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 or not ::stage() should simulate a change in the lock file. + * + * @param bool $value + * (optional) Whether or not 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..2a8135ff58f4 --- /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) { + 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..20f1334dcf26 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -0,0 +1,179 @@ +<?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'; + + /** + * The stage. + * + * @var \Drupal\package_manager\StageBase + */ + protected $stage; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + + /** + * Constructs an ApiController object. + * + * @param \Drupal\package_manager\StageBase $stage + * The stage. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(StageBase $stage, PathLocator $path_locator) { + $this->stage = $stage; + $this->pathLocator = $path_locator; + } + + /** + * {@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, + $container->get(PathLocator::class), + ); + } + + /** + * 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..17e9dd3ac632 --- /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..bac2fbbb78ea --- /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..4daac9cdbf47 --- /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() { + $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..1e75a1251eca --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php @@ -0,0 +1,179 @@ +<?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 { + $priority = defined('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY') ? PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY : 5; + + return [ + PreCreateEvent::class => ['handleEvent', $priority], + PostCreateEvent::class => ['handleEvent', $priority], + PreRequireEvent::class => ['handleEvent', $priority], + PostRequireEvent::class => ['handleEvent', $priority], + PreApplyEvent::class => ['handleEvent', $priority], + PostApplyEvent::class => ['handleEvent', $priority], + StatusCheckEvent::class => ['handleEvent', $priority], + ]; + } + + /** + * 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..9635c3c3884c --- /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) { + 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..dac2a0fd5a0d --- /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..8864935e00d4 --- /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..dd47383c066f --- /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..cf4ecae3b869 --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -0,0 +1,678 @@ +<?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\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; + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if ($this->metadataServer) { + $this->metadataServer->stop(); + } + parent::tearDown(); + } + + /** + * Data provider for tests which use all of the core project templates. + * + * @return string[][] + * The test cases. + */ + public 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=10', + '-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(); + // 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) { + 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) { + 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 + // and 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; + $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=10: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.1 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'], + ); + // 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 $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(). + $this->assertSame(200, $session->getStatusCode(), 'Error response: ' . $session->getPage()->getContent()); + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { + 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..ad1ce9e98dd2 --- /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() { + $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 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..8adc2e8ab8c9 --- /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..1a81004bfec7 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php @@ -0,0 +1,93 @@ +<?php + +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 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..f4e4c8f5c226 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\EventSubscriber\ChangeLogger; +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) { + parent::register($container); + + $container->getDefinition(ChangeLogger::class) + ->setMethodCalls([ + ['setLogger', [$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..bf5d8c8c42be --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -0,0 +1,552 @@ +<?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\ProcessOutputCallback; +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\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); + + // 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]->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 or not 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 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, + ], + ]); + + /** @var \Drupal\package_manager\ProcessOutputCallback $callback */ + [, $callback] = $arguments_passed_to_runner; + $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::type(ProcessOutputCallback::class) + )->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..ea73f7c32ad5 --- /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..83020a8e8457 --- /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 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 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..30f78859c34f --- /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 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 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 function providerComplexInvalidCases(): \Generator { + $valid_cases = iterator_to_array($this->providerSimpleValidCases()); + $invalid_cases = iterator_to_array($this->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..df41794a2ebd --- /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 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 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..a541b615e44b --- /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 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..43da1afd8744 --- /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 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..3dfe9b28990b --- /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 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..806f1b0a260e --- /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/ExecutableFinderTest.php b/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php new file mode 100644 index 000000000000..c1ee4ef5d1cb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\ExecutableFinder; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder; + +/** + * @covers \Drupal\package_manager\ExecutableFinder + * @group package_manager + * @internal + */ +class ExecutableFinderTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + // Mock a Symfony executable finder that always returns /dev/null. + $container->set(SymfonyExecutableFinder::class, new class extends SymfonyExecutableFinder { + + /** + * {@inheritdoc} + */ + public function find($name, $default = NULL, array $extraDirs = []): ?string { + return '/dev/null'; + } + + }); + } + + /** + * Tests that the executable finder looks for paths in configuration. + */ + public function testCheckConfigurationForExecutablePath(): void { + $this->config('package_manager.settings') + ->set('executables.composer', '/path/to/composer') + ->save(); + + $executable_finder = $this->container->get(ExecutableFinderInterface::class); + $this->assertInstanceOf(ExecutableFinder::class, $executable_finder); + $this->assertSame('/path/to/composer', $executable_finder->find('composer')); + $this->assertSame('/dev/null', $executable_finder->find('rsync')); + } + +} 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..f8235bdde32a --- /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..2e92ca984c07 --- /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/FileSyncerFactoryTest.php b/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php new file mode 100644 index 000000000000..780a6f703295 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use PhpTuf\ComposerStager\API\FileSyncer\Service\FileSyncerInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Service\PhpFileSyncerInterface; +use PhpTuf\ComposerStager\API\FileSyncer\Service\RsyncFileSyncerInterface; + +/** + * @covers \Drupal\package_manager\FileSyncerFactory + * @group package_manager + * @internal + */ +class FileSyncerFactoryTest extends KernelTestBase { + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager', 'update']; + + /** + * Data provider for testFactory(). + * + * @return mixed[][] + * The test cases. + */ + public function providerFactory(): array { + return [ + 'rsync file syncer' => [ + 'configured syncer' => 'rsync', + 'expected syncer' => RsyncFileSyncerInterface::class, + ], + 'php file syncer' => [ + 'configured syncer' => 'php', + 'expected syncer' => PhpFileSyncerInterface::class, + ], + 'no preference' => [ + 'configured syncer' => NULL, + 'expected syncer' => FileSyncerInterface::class, + ], + ]; + } + + /** + * Tests creating a file syncer using our specialized factory class. + * + * @param string|null $configured_syncer + * The syncer to use, as configured in auto_updates.settings. Can be + * 'rsync', 'php', or NULL. + * @param string $expected_syncer + * The expected syncer class. + * + * @dataProvider providerFactory + */ + public function testFactory(?string $configured_syncer, string $expected_syncer): void { + $this->config('package_manager.settings') + ->set('file_syncer', $configured_syncer) + ->save(); + + $this->assertInstanceOf($expected_syncer, $this->container->get(FileSyncerInterface::class)); + } + +} 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..00b5c370dfec --- /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() { + $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..6a9fffd534a7 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php @@ -0,0 +1,168 @@ +<?php + +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..1c000d02871d --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -0,0 +1,215 @@ +<?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) { + 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, 'STATE_KEY'); + $state_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 ($state_key) { + $this->container->get('state')->delete($state_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 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..cb24ffad1cc4 --- /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 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..eef549a2d3ff --- /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..ee5d976e4cce --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -0,0 +1,524 @@ +<?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) { + parent::enableModules($modules); + $this->registerPostUpdateFunctions(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + 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'); + + foreach ($this->disableValidators as $service_id) { + if ($container->hasDefinition($service_id)) { + $container->getDefinition($service_id)->clearTag('event_subscriber'); + } + } + } + + /** + * 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'; + + /** + * {@inheritdoc} + * + * 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..ed807d32dd0f --- /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..a36dd1cd6c59 --- /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..cdf598e37cf5 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php @@ -0,0 +1,123 @@ +<?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) { + 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 { + + /** + * {@inheritdoc} + */ + 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..9c5f2b1bc73a --- /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() { + $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..7ef8a0b61520 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php @@ -0,0 +1,150 @@ +<?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; + +/** + * @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) { + parent::register($container); + + $this->mockDatabase = $this->prophesize(Connection::class); + $this->mockDatabase->driver() + ->willReturn('sqlite') + ->shouldBeCalled(); + + $container->getDefinition(SqliteDatabaseExcluder::class) + ->setArgument('$database', $this->mockDatabase->reveal()); + } + + /** + * Data provider for ::testSqliteDatabaseFilesExcluded(). + * + * @return array[] + * The test cases. + */ + public 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..6b33623ffbe7 --- /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..48ed3b091ccb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php @@ -0,0 +1,230 @@ +<?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 except composer.json, composer.lock and vendor into the + // webroot. + $fs->mkdir($fake_site_with_nested_webroot . DIRECTORY_SEPARATOR . 'webroot'); + $paths_in_project_root = glob("$fake_site_with_nested_webroot/*"); + $root_paths = [ + $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', + ]; + foreach ($paths_in_project_root as $path_in_project_root) { + if (!in_array($path_in_project_root, $root_paths, 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 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'); + } + +} 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..97be633cd4e6 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php @@ -0,0 +1,50 @@ +<?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', + 'vendor/web.config', + ]; + 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..2c3cb64f4ca0 --- /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 schema updates to install. 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 schema updates to install. 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 schema updates to install. 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..3cffebee46bd --- /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 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..b69a23370551 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php @@ -0,0 +1,251 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +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(); + + (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(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // @todo Remove this in https://drupal.org/i/3358504, once + // packages.drupal.org supports TUF. + $container->getDefinition(PhpTufValidator::class) + ->addTag('event_subscriber'); + } + + /** + * 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 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 defined' => [ + [ + 'repositories.drupal' => FALSE, + ], + [ + t('The <code>https://packages.drupal.org</code> Composer repository must be defined in <code>composer.json</code>.'), + ], + ], + 'packages.drupal.org not using TUF' => [ + [ + 'repositories.drupal' => [ + 'type' => 'composer', + 'url' => 'https://packages.drupal.org/8', + ], + ], + [ + t('TUF is not enabled for the https://packages.drupal.org/8 repository.'), + ], + ], + ]; + } + + /** + * Data provider for testing invalid plugin configuration in the stage. + * + * @return \Generator + * The test cases. + */ + public function providerInvalidConfigurationInStage(): \Generator { + foreach ($this->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..2c149365ecb6 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php @@ -0,0 +1,37 @@ +<?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'); + $method->setAccessible(TRUE); + $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..596f1ffb3214 --- /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 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 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 or not 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 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..6dac105bd9f7 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -0,0 +1,142 @@ +<?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; + +/** + * @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) { + parent::register($container); + + $container->getDefinition(RsyncValidator::class) + ->setArgument('$executableFinder', $this->executableFinder->reveal()); + } + + /** + * Data provider for ::testConfiguredFileSyncer(). + * + * @return array[] + * The test cases. + */ + public function providerConfiguredFileSyncer(): array { + return [ + 'using rsync' => [ + 'rsync', + [], + [], + ], + 'not using rsync, help not installed' => [ + 'php', + [ + ValidationResult::createWarning([ + t('You are currently using the PHP file syncer, which has known problems and is not stable. It is strongly recommended to switch back to the default <em>rsync</em> file syncer instead.'), + ]), + ], + [], + ], + 'not using rsync, help installed' => [ + 'php', + [ + ValidationResult::createWarning([ + t('You are currently using the PHP file syncer, which has known problems and is not stable. It is strongly recommended to switch back to the default <em>rsync</em> file syncer instead. See the <a href="/admin/help/package_manager#package-manager-faq-rsync">Package Manager help</a> for more information on how to resolve this.'), + ]), + ], + ['help'], + ], + ]; + } + + /** + * Tests that the file_syncer config option is validated. + * + * @param string $configured_syncer + * The file_syncer value in package_manager.settings config. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected status check results. + * @param string[] $additional_modules + * Any additional modules to enable. + * + * @dataProvider providerConfiguredFileSyncer + */ + public function testConfiguredFileSyncer(string $configured_syncer, array $expected_results, array $additional_modules): void { + if ($additional_modules) { + $this->enableModules($additional_modules); + } + + $this->config('package_manager.settings') + ->set('file_syncer', $configured_syncer) + ->save(); + + $this->assertStatusCheckResults($expected_results); + } + + /** + * Tests that the stage is created even if the PHP file syncer is selected. + */ + public function testPreCreateAllowsPhpSyncer(): void { + $this->config('package_manager.settings') + ->set('file_syncer', 'php') + ->save(); + + $this->assertResults([]); + } + + /** + * 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..feaf06f75b19 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\ExecutableFinder; +use Drupal\package_manager\FileSyncerFactory; +use Drupal\package_manager\ProcessFactory; +use Drupal\package_manager\TranslatableStringFactory; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use PhpTuf\ComposerStager\API\FileSyncer\Factory\FileSyncerFactoryInterface; +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, + FileSyncerFactoryInterface::class => FileSyncerFactory::class, + ProcessFactoryInterface::class => ProcessFactory::class, + TranslatableFactoryInterface::class => TranslatableStringFactory::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..137cc7f023e8 --- /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 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..90681408c3ae --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php @@ -0,0 +1,833 @@ +<?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) { + 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 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 get 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 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 or not 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( + new PreconditionException( + $this->prophesize(PreconditionInterface::class)->reveal(), + $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 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|null $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)); + } + $throwable = new $thrown_class(...$throwable_arguments); + LoggingCommitter::setException($throwable); + + 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) { + $class_in_message = get_class($throwable); + $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 $class_in_message, 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 = new \Exception('Thrown by the committer.'); + LoggingCommitter::setException($thrown); + try { + $stage->apply(); + $this->fail('Expected an exception.'); + } + catch (ApplyFailedException $e) { + $this->assertStringContainsString($thrown->getMessage(), $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->getMessage() . "\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 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) { + $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() { + $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 function providerFailureDuringComposerStagerOperations(): array { + return [ + [LoggingBeginner::class], + [NoOpStager::class], + [LoggingCommitter::class], + ]; + } + + /** + * Tests when Composer Stager throws an exception during an operation. + * + * @param 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 = new \Exception("$throwing_class is angry!", 1024); + $throwing_class::setException($exception); + + $expected_message = preg_quote($exception->getMessage()); + 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->assertSame($exception, $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..516400a1f93d --- /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) { + 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 { + array_push($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 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..8f8047022dcc --- /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..0e1dbc483ced --- /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 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..58e83e945b08 --- /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..6cf8701d5e67 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -0,0 +1,219 @@ +<?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', + ]) + ->commitChanges(); + } + + /** + * Data provider for testException(). + * + * @return mixed[][] + * The test cases. + */ + public 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', + ], + [], + ], + ]; + } + + /** + * 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..fe77dd7983fb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php @@ -0,0 +1,239 @@ +<?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); + } + } + + /** + * Data provider for ::testSymlinkToDirectory(). + * + * @return array[] + * The test cases. + */ + public function providerSymlinkToDirectory(): array { + return [ + 'php' => [ + 'php', + [ + ValidationResult::createError([ + t('The %which directory at <em class="placeholder"><PROJECT_ROOT></em> contains symlinks that point to a directory, which is not supported. The first one is <em class="placeholder"><PROJECT_ROOT>/modules/custom/example_module</em>.', [ + '%which' => 'active', + ]), + ]), + ], + ], + 'rsync' => [ + 'rsync', + [], + ], + ]; + } + + /** + * Tests what happens when there is a symlink to a directory. + * + * @param string $file_syncer + * The file syncer to use. Can be `php` or `rsync`. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerSymlinkToDirectory + */ + public function testSymlinkToDirectory(string $file_syncer, array $expected_results): 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'); + + $this->config('package_manager.settings') + ->set('file_syncer', $file_syncer) + ->save(); + + // 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($expected_results); + } + + /** + * 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..d05e3043fe23 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php @@ -0,0 +1,41 @@ +<?php + +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..10049ed7c3f8 --- /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 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 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..70474890e027 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php @@ -0,0 +1,109 @@ +<?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 Automatic Updates class that directly extends a + * Core test class, i.e., any class that does NOT extend a test class in a + * Automatic Updates test namespace. If that class implements this hook, 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_loader = $GLOBALS['loader']; + assert($class_loader instanceof ClassLoader); + $object = new \ReflectionObject($class_loader); + $property = $object->getProperty('vendorDir'); + $property->setAccessible(TRUE); + $vendor_directory = $property->getValue($class_loader); + 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..cd27ee4d8ac5 --- /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..9554ba30436f --- /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) { + // 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) { + $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/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/PathLocatorTest.php b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php new file mode 100644 index 000000000000..cd9810762f61 --- /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 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..84b9ed63e814 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php @@ -0,0 +1,128 @@ +<?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->assertNull($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->assertNull($callback->getOutput()); + $this->assertNull($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")); + foreach (explode("\n", $json) as $line) { + $callback(OutputTypeEnum::OUT, "$line\n"); + } + $this->assertSame("$json\n", $callback->getOutput()); + // Ensure that parseJsonOutput() can parse the data without errors. + $this->assertSame($data, $callback->parseJsonOutput()); + $this->assertNull($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()); + $this->assertSame('Oh no, what happened?Really what happened?!', $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, '{}'); + $callback(OutputTypeEnum::ERR, 'new Error 1!'); + $callback(OutputTypeEnum::ERR, 'new Error 2!'); + // The output buffer will no longer be valid JSON, so don't try to parse it. + $this->assertSame("$json\n{}", $callback->getOutput()); + $expected_error = 'Oh no, what happened?Really what happened?!new Error 1!new Error 2!'; + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning($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->assertNull($callback->getOutput()); + $this->assertNull($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..2ca254d0a09e --- /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 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..b04ef378f250 --- /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'); + $method->setAccessible(TRUE); + + 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 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} + */ + 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..34ef45362bdc --- /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) { + $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 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..4450ff839a65 --- /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 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 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..227daef65323 --- /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/scripts/auto-update b/core/scripts/auto-update new file mode 100644 index 000000000000..9df2a29b0845 --- /dev/null +++ b/core/scripts/auto-update @@ -0,0 +1,46 @@ +#!/usr/bin/env php +<?php + +/** + * @file + * Provides a terminal command for performing automatic updates. + */ + +use Drupal\auto_updates\Commands\PostApplyCommand; +use Drupal\auto_updates\Commands\RunCommand; +use Symfony\Component\Console\Application; + +if (PHP_SAPI !== 'cli') { + throw new \RuntimeException('This command must be run from the command line.'); +} + +// Find the autoloader. We know that Automatic Updates is installed somewhere +// in a Drupal code base, so move up the file system until we find +// `./vendor/autoload.php`. +$current_dir = __DIR__; +$previous_dir = NULL; +while ($current_dir !== $previous_dir) { + $file = $current_dir . '/vendor/autoload.php'; + if (file_exists($file)) { + /** @var \Composer\Autoload\ClassLoader $autoloader */ + $autoloader = require_once $file; + break; + } + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); +} + +if (empty($autoloader)) { + throw new \RuntimeException('The autoloader could not be found. Did you run `composer install`?'); +} + +// Automatic Updates' namespace is not available for autoloading because it is +// a Drupal module, which means Drupal must be booted up in order to access it. +// Since Drupal isn't booted yet, we need to make the autoloader aware of the +// command namespace. +$autoloader->addPsr4('Drupal\\auto_updates\\Commands\\', __DIR__ . '/../modules/auto_updates/src/Commands'); + +$application = new Application('Automatic Updates', '3.0.0'); +$application->add(new RunCommand($autoloader)); +$application->add(new PostApplyCommand($autoloader)); +$application->setDefaultCommand('run')->run(); diff --git a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php index cfbd463ddb32..82ac3c93f327 100644 --- a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php @@ -30,9 +30,12 @@ class ComposerProjectTemplatesTest extends ComposerBuildTestBase { /** * The minimum stability requirement for dependencies. * + * @todo Temporarily changing to 'beta' to allow php-tuf/composer-stager + * dependency. This will be done in https://drupal.org/i/3331078. + * * @see https://getcomposer.org/doc/04-schema.md#minimum-stability */ - protected const MINIMUM_STABILITY = 'stable'; + protected const MINIMUM_STABILITY = 'beta'; /** * The order of stability strings from least stable to most stable. -- GitLab