From 783be1d6211cfacc9caaf6d80f9bc9c19135d197 Mon Sep 17 00:00:00 2001 From: Ted Bowman <ted@tedbow.com> Date: Thu, 31 Mar 2022 12:56:56 -0400 Subject: [PATCH] Contrib: cspell fix - https://git.drupalcode.org/project/automatic_updates/-/commit/6109421c063e54f211ea615efde62c81fec34bfd --- core/misc/cspell/dictionary.txt | 2 +- .../modules/auto_updates/auto_updates.install | 7 + core/modules/auto_updates/auto_updates.module | 33 ++- .../auto_updates/auto_updates.routing.yml | 12 + .../auto_updates/auto_updates.services.yml | 31 ++- .../auto_updates/src/BatchProcessor.php | 62 ++++- .../src/Controller/UpdateController.php | 21 +- core/modules/auto_updates/src/CronUpdater.php | 80 +++--- .../src/Event/ReadinessCheckEvent.php | 12 +- .../auto_updates/src/Form/UpdateReady.php | 86 ++++-- .../auto_updates/src/Form/UpdaterForm.php | 122 +++++--- core/modules/auto_updates/src/ProjectInfo.php | 106 +++++++ .../auto_updates/src/ReleaseChooser.php | 132 +++++++++ .../src/Routing/RouteSubscriber.php | 39 +++ .../auto_updates/src/UpdateRecommender.php | 69 ----- core/modules/auto_updates/src/Updater.php | 4 +- .../src/Validation/AdminReadinessMessages.php | 42 +-- .../Validation/ReadinessValidationManager.php | 12 +- .../Validator/CronUpdateVersionValidator.php | 100 +++++++ .../src/Validator/UpdateVersionValidator.php | 204 ++++++++------ .../auto_updates/src/VersionParsingTrait.php | 37 +++ .../drupal.9.8.2-older-sec-release.xml | 102 +++++++ .../fixtures/release-history/drupal.9.8.2.xml | 38 ++- .../release-history/semver_test.1.1.xml | 184 ++++++++++++ .../tests/fixtures/staged/9.8.1/composer.json | 7 + .../9.8.1/vendor/composer/installed.json | 9 + .../auto_updates_test.install | 17 ++ .../auto_updates_test.routing.yml | 4 + .../auto_updates_test.services.yml | 4 - .../src/AutoUpdatesTestServiceProvider.php | 24 -- .../src/Form/TestUpdateReady.php | 19 -- .../src/Routing/RouteSubscriber.php | 22 -- .../auto_updates_test/src/TestController.php | 17 +- .../Validator/TestPendingUpdatesValidator.php | 30 -- .../tests/src/Build/CoreUpdateTest.php | 9 +- .../AutoUpdatesFunctionalTestBase.php | 35 ++- .../Functional/ReadinessValidationTest.php | 24 +- .../tests/src/Functional/UpdateLockTest.php | 11 +- .../tests/src/Functional/UpdaterFormTest.php | 261 +++++++++++++++--- .../src/Kernel/AutoUpdatesKernelTestBase.php | 19 +- .../tests/src/Kernel/CronUpdaterTest.php | 16 +- .../PackageManagerReadinessChecksTest.php | 1 + .../ReadinessValidationManagerTest.php | 4 +- .../StagedDatabaseUpdateValidatorTest.php | 4 + .../UpdateVersionValidatorTest.php | 86 +----- .../tests/src/Kernel/ReleaseChooserTest.php | 171 ++++++++++++ .../src/Kernel/UpdateRecommenderTest.php | 47 ---- .../tests/src/Kernel/UpdaterTest.php | 2 +- .../tests/src/Unit/ProjectInfoTest.php | 194 +++++++++++++ .../package_manager/package_manager.module | 15 +- .../package_manager.services.yml | 15 + .../src/Event/PreOperationStageEvent.php | 2 +- .../src/PackageManagerUninstallValidator.php | 42 +++ .../PackageManagerUninstallValidator.php | 88 ++++++ core/modules/package_manager/src/Stage.php | 139 ++++++++-- .../src/Validator/LockFileValidator.php | 28 +- .../src/Validator/MultisiteValidator.php | 74 +++++ .../tests/fixtures/alpha/1.0.0/composer.json | 5 + .../tests/fixtures/alpha/1.1.0/composer.json | 5 + .../updated_module/1.0.0/composer.json | 5 + .../1.0.0/updated_module.info.yml | 4 + .../1.0.0/updated_module.module | 27 ++ .../1.0.0/updated_module.permissions.yml | 4 + .../1.0.0/updated_module.routing.yml | 12 + .../updated_module/1.1.0/composer.json | 5 + .../1.1.0/src/PostApplySubscriber.php | 52 ++++ .../1.1.0/updated_module.info.yml | 4 + .../1.1.0/updated_module.module | 33 +++ .../1.1.0/updated_module.permissions.yml | 4 + .../1.1.0/updated_module.routing.yml | 12 + .../1.1.0/updated_module.services.yml | 7 + .../src/InvocationRecorderBase.php | 2 +- .../package_manager_test_api.routing.yml | 6 +- .../package_manager_test_api.services.yml | 10 + .../src/ApiController.php | 45 ++- .../src/SystemChangeRecorder.php | 129 +++++++++ .../package_manager_test_fixture.info.yml | 6 + .../package_manager_test_fixture.services.yml | 8 + .../src/EventSubscriber/FixtureStager.php | 88 ++++++ .../src/EventSubscriber/TestSubscriber.php | 19 ++ .../tests/src/Build/PackageUpdateTest.php | 125 +++++++++ .../tests/src/Build/StagedUpdateTest.php | 87 ------ .../tests/src/Kernel/ExcludedPathsTest.php | 11 + .../src/Kernel/LockFileValidatorTest.php | 16 ++ .../src/Kernel/MultisiteValidatorTest.php | 62 +++++ .../Kernel/PackageManagerKernelTestBase.php | 10 +- .../tests/src/Kernel/StageEventsTest.php | 13 + .../tests/src/Kernel/StageTest.php | 204 +++++++++++++- 88 files changed, 3228 insertions(+), 769 deletions(-) create mode 100644 core/modules/auto_updates/src/ProjectInfo.php create mode 100644 core/modules/auto_updates/src/ReleaseChooser.php create mode 100644 core/modules/auto_updates/src/Routing/RouteSubscriber.php delete mode 100644 core/modules/auto_updates/src/UpdateRecommender.php create mode 100644 core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php create mode 100644 core/modules/auto_updates/src/VersionParsingTrait.php create mode 100644 core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml create mode 100644 core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml create mode 100644 core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json create mode 100644 core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php delete mode 100644 core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php create mode 100644 core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php create mode 100644 core/modules/package_manager/src/PackageManagerUninstallValidator.php create mode 100644 core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php create mode 100644 core/modules/package_manager/src/Validator/MultisiteValidator.php create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php create mode 100644 core/modules/package_manager/tests/src/Build/PackageUpdateTest.php delete mode 100644 core/modules/package_manager/tests/src/Build/StagedUpdateTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 95836d0313a5..bd36df7b19a3 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1700,4 +1700,4 @@ zzgroup Ãœbersetzung Ã¥wesome über -È„chÈ +È„chÈ \ No newline at end of file diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install index b7ab9425fef6..961829e7a15e 100644 --- a/core/modules/auto_updates/auto_updates.install +++ b/core/modules/auto_updates/auto_updates.install @@ -7,6 +7,13 @@ use Drupal\auto_updates\Validation\ReadinessRequirements; +/** + * Implements hook_uninstall(). + */ +function auto_updates_uninstall() { + \Drupal::service('auto_updates.updater')->destroy(TRUE); +} + /** * Implements hook_requirements(). */ diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module index 09d33000f6d8..b7580c9a3bf7 100644 --- a/core/modules/auto_updates/auto_updates.module +++ b/core/modules/auto_updates/auto_updates.module @@ -5,25 +5,34 @@ * Contains hook implementations for Automatic Updates. */ +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\ProjectInfo; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\auto_updates\CronUpdater; -use Drupal\auto_updates\UpdateRecommender; use Drupal\auto_updates\Validation\AdminReadinessMessages; use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; +use Drupal\system\Controller\DbUpdateController; use Drupal\update\ProjectSecurityData; /** * Implements hook_help(). */ function auto_updates_help($route_name, RouteMatchInterface $route_match) { - // @todo Fully document all the modules features in - // https://www.drupal.org/i/3253591. 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('auto_updates.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 Composer @version or later available as an executable, 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' => ComposerExecutableValidator::MINIMUM_COMPOSER_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>'; return $output; } @@ -139,9 +148,8 @@ function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInt * Implements hook_form_FORM_ID_alter() for 'update_settings' form. */ function auto_updates_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) { - $recommender = new UpdateRecommender(); - $drupal_project = $recommender->getProjectInfo(); - $version = ExtensionVersion::createFromVersionString($drupal_project['existing_version']); + $project_info = new ProjectInfo(); + $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion()); $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion(); // @todo In https://www.drupal.org/node/2998285 use the update XML to // determine when the installed of core will become unsupported. @@ -200,3 +208,16 @@ function auto_updates_local_tasks_alter(array &$local_tasks) { } } } + +/** + * 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']; + } + } +} diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml index 42225ddebb92..c4e32b7106df 100644 --- a/core/modules/auto_updates/auto_updates.routing.yml +++ b/core/modules/auto_updates/auto_updates.routing.yml @@ -5,6 +5,8 @@ auto_updates.update_readiness: _title: 'Update readiness checking' requirements: _permission: 'administer software updates' + options: + _maintenance_access: TRUE auto_updates.confirmation_page: path: '/admin/automatic-update-ready/{stage_id}' defaults: @@ -13,12 +15,16 @@ auto_updates.confirmation_page: requirements: _permission: 'administer software updates' _access_update_manager: 'TRUE' + options: + _maintenance_access: TRUE auto_updates.finish: path: '/automatic-update/finish' defaults: _controller: '\Drupal\auto_updates\Controller\UpdateController::onFinish' requirements: _permission: 'administer software updates' + options: + _maintenance_access: TRUE # Links to our updater form appear in three different sets of local tasks. To ensure the breadcrumbs and paths are # consistent with the other local tasks in each set, we need two separate routes to the same form. auto_updates.report_update: @@ -30,6 +36,8 @@ auto_updates.report_update: _permission: 'administer software updates' options: _admin_route: TRUE + _maintenance_access: TRUE + _auto_updates_readiness_messages: skip auto_updates.module_update: path: '/admin/modules/automatic-update' defaults: @@ -39,6 +47,8 @@ auto_updates.module_update: _permission: 'administer software updates' options: _admin_route: TRUE + _maintenance_access: TRUE + _auto_updates_readiness_messages: skip auto_updates.theme_update: path: '/admin/theme/automatic-update' defaults: @@ -48,3 +58,5 @@ auto_updates.theme_update: _permission: 'administer software updates' options: _admin_route: TRUE + _maintenance_access: TRUE + _auto_updates_readiness_messages: skip diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml index e405c39b16c4..579fbbbaac2f 100644 --- a/core/modules/auto_updates/auto_updates.services.yml +++ b/core/modules/auto_updates/auto_updates.services.yml @@ -1,4 +1,8 @@ services: + auto_updates.route_subscriber: + class: Drupal\auto_updates\Routing\RouteSubscriber + tags: + - { name: event_subscriber } auto_updates.readiness_validation_manager: class: Drupal\auto_updates\Validation\ReadinessValidationManager arguments: @@ -14,6 +18,7 @@ services: auto_updates.updater: class: Drupal\auto_updates\Updater arguments: + - '@config.factory' - '@package_manager.path_locator' - '@package_manager.beginner' - '@package_manager.stager' @@ -21,11 +26,13 @@ services: - '@file_system' - '@event_dispatcher' - '@tempstore.shared' + - '@datetime.time' auto_updates.cron_updater: class: Drupal\auto_updates\CronUpdater arguments: - - '@config.factory' + - '@auto_updates.cron_release_chooser' - '@logger.factory' + - '@config.factory' - '@package_manager.path_locator' - '@package_manager.beginner' - '@package_manager.stager' @@ -33,6 +40,7 @@ services: - '@file_system' - '@event_dispatcher' - '@tempstore.shared' + - '@datetime.time' auto_updates.staged_projects_validator: class: Drupal\auto_updates\Validator\StagedProjectsValidator arguments: @@ -52,6 +60,21 @@ services: - '@config.factory' tags: - { name: event_subscriber } + auto_updates.cron_update_version_validator: + class: Drupal\auto_updates\Validator\CronUpdateVersionValidator + arguments: + - '@string_translation' + - '@config.factory' + tags: + - { name: event_subscriber } + auto_updates.release_chooser: + class: Drupal\auto_updates\ReleaseChooser + arguments: + - '@auto_updates.update_version_validator' + auto_updates.cron_release_chooser: + class: Drupal\auto_updates\ReleaseChooser + arguments: + - '@auto_updates.cron_update_version_validator' auto_updates.composer_executable_validator: class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck arguments: @@ -82,6 +105,12 @@ services: - '@package_manager.validator.file_system' tags: - { name: event_subscriber } + auto_updates.validator.multisite: + class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck + arguments: + - '@package_manager.validator.multisite' + tags: + - { name: event_subscriber } auto_updates.cron_frequency_validator: class: Drupal\auto_updates\Validator\CronFrequencyValidator arguments: diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php index 120aa5904d63..ffacbfd6ae94 100644 --- a/core/modules/auto_updates/src/BatchProcessor.php +++ b/core/modules/auto_updates/src/BatchProcessor.php @@ -4,6 +4,7 @@ use Drupal\Core\Url; use Drupal\package_manager\Exception\StageValidationException; +use Drupal\system\Controller\DbUpdateController; use Symfony\Component\HttpFoundation\RedirectResponse; /** @@ -11,6 +12,20 @@ */ 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'; + /** * Gets the updater service. * @@ -66,8 +81,8 @@ protected static function handleException(\Throwable $error, array &$context): v */ public static function begin(array $project_versions, array &$context): void { try { - $stage_unique = static::getUpdater()->begin($project_versions); - $context['results']['stage_id'] = $stage_unique; + $stage_id = static::getUpdater()->begin($project_versions); + \Drupal::service('session')->set(static::STAGE_ID_SESSION_KEY, $stage_id); } catch (\Throwable $e) { static::handleException($e, $context); @@ -84,10 +99,11 @@ public static function begin(array $project_versions, array &$context): void { */ public static function stage(array &$context): void { try { - $stage_id = $context['results']['stage_id']; + $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); static::getUpdater()->claim($stage_id)->stage(); } catch (\Throwable $e) { + static::clean($stage_id, $context); static::handleException($e, $context); } } @@ -142,8 +158,9 @@ public static function clean(string $stage_id, array &$context): void { */ public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse { if ($success) { + $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY); $url = Url::fromRoute('auto_updates.confirmation_page', [ - 'stage_id' => $results['stage_id'], + 'stage_id' => $stage_id, ]); return new RedirectResponse($url->setAbsolute()->toString()); } @@ -162,6 +179,8 @@ public static function finishStage(bool $success, array $results, array $operati * A list of the operations that had not been completed by the batch API. */ public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse { + \Drupal::service('session')->remove(static::STAGE_ID_SESSION_KEY); + if ($success) { $url = Url::fromRoute('auto_updates.finish') ->setAbsolute() @@ -189,4 +208,39 @@ protected static function handleBatchError(array $results): void { } } + /** + * 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) { + 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/Controller/UpdateController.php b/core/modules/auto_updates/src/Controller/UpdateController.php index fe840ef2c70f..42a2a536abe9 100644 --- a/core/modules/auto_updates/src/Controller/UpdateController.php +++ b/core/modules/auto_updates/src/Controller/UpdateController.php @@ -2,11 +2,14 @@ namespace Drupal\auto_updates\Controller; +use Drupal\auto_updates\BatchProcessor; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\State\StateInterface; 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. @@ -28,9 +31,12 @@ class UpdateController extends ControllerBase { * * @param \Drupal\package_manager\Validator\PendingUpdatesValidator $pending_updates_validator * The pending updates validator. + * @param \Drupal\Core\State\StateInterface $state + * The state service. */ - public function __construct(PendingUpdatesValidator $pending_updates_validator) { + public function __construct(PendingUpdatesValidator $pending_updates_validator, StateInterface $state) { $this->pendingUpdatesValidator = $pending_updates_validator; + $this->stateService = $state; } /** @@ -38,7 +44,8 @@ public function __construct(PendingUpdatesValidator $pending_updates_validator) */ public static function create(ContainerInterface $container) { return new static( - $container->get('package_manager.validator.pending_updates') + $container->get('package_manager.validator.pending_updates'), + $container->get('state') ); } @@ -49,10 +56,13 @@ public static function create(ContainerInterface $container) { * 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(): RedirectResponse { + public function onFinish(Request $request): RedirectResponse { if ($this->pendingUpdatesValidator->updatesExist()) { $message = $this->t('Please apply database updates to complete the update process.'); $url = Url::fromRoute('system.db_update'); @@ -60,6 +70,11 @@ public function onFinish(): RedirectResponse { else { $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); + } } $this->messenger()->addStatus($message); return new RedirectResponse($url->setAbsolute()->toString()); diff --git a/core/modules/auto_updates/src/CronUpdater.php b/core/modules/auto_updates/src/CronUpdater.php index 38317a8608b7..0b400a1e43ab 100644 --- a/core/modules/auto_updates/src/CronUpdater.php +++ b/core/modules/auto_updates/src/CronUpdater.php @@ -2,7 +2,6 @@ namespace Drupal\auto_updates; -use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\package_manager\Exception\StageValidationException; @@ -37,32 +36,32 @@ class CronUpdater extends Updater { public const ALL = 'patch'; /** - * The config factory service. + * The logger. * - * @var \Drupal\Core\Config\ConfigFactoryInterface + * @var \Psr\Log\LoggerInterface */ - protected $configFactory; + protected $logger; /** - * The logger. + * The cron release chooser service. * - * @var \Psr\Log\LoggerInterface + * @var \Drupal\auto_updates\ReleaseChooser */ - protected $logger; + protected $releaseChooser; /** * Constructs a CronUpdater object. * - * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory - * The config factory service. + * @param \Drupal\auto_updates\ReleaseChooser $release_chooser + * The cron release chooser service. * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory * The logger channel factory. * @param mixed ...$arguments * Additional arguments to pass to the parent constructor. */ - public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ...$arguments) { + public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, ...$arguments) { parent::__construct(...$arguments); - $this->configFactory = $config_factory; + $this->releaseChooser = $release_chooser; $this->logger = $logger_factory->get('auto_updates'); } @@ -70,50 +69,35 @@ public function __construct(ConfigFactoryInterface $config_factory, LoggerChanne * Handles updates during cron. */ public function handleCron(): void { - $level = $this->configFactory->get('auto_updates.settings') - ->get('cron'); - - // If automatic updates are disabled, bail out. - if ($level === static::DISABLED) { + if ($this->isDisabled()) { return; } - $recommender = new UpdateRecommender(); - try { - $recommended_release = $recommender->getRecommendedRelease(TRUE); - } - catch (\Throwable $e) { - $this->logger->error($e->getMessage()); - return; - } - - // If we're already up-to-date, there's nothing else we need to do. - if ($recommended_release === NULL) { - return; + $next_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor(); + if ($next_release) { + $this->performUpdate($next_release->getVersion()); } + } - $project = $recommender->getProjectInfo(); - if (empty($project['existing_version'])) { + /** + * Performs the update. + * + * @param string $update_version + * The version to which to update. + */ + private function performUpdate(string $update_version): void { + $installed_version = (new ProjectInfo())->getInstalledVersion(); + if (empty($installed_version)) { $this->logger->error('Unable to determine the current version of Drupal core.'); return; } - // If automatic updates are only enabled for security releases, bail out if - // the recommended release is not a security release. - if ($level === static::SECURITY && !$recommended_release->isSecurityRelease()) { - return; - } - - // @todo Use the queue to add update jobs allowing jobs to span multiple - // cron runs. - $recommended_version = $recommended_release->getVersion(); - // 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 { $this->begin([ - 'drupal' => $recommended_version, + 'drupal' => $update_version, ]); $this->stage(); $this->apply(); @@ -121,8 +105,8 @@ public function handleCron(): void { $this->logger->info( 'Drupal core has been updated from %previous_version to %update_version', [ - '%previous_version' => $project['existing_version'], - '%update_version' => $recommended_version, + '%previous_version' => $installed_version, + '%update_version' => $update_version, ] ); } @@ -149,6 +133,16 @@ public function handleCron(): void { } } + /** + * Determines if cron updates are disabled. + * + * @return bool + * TRUE if cron updates are disabled, otherwise FALSE. + */ + private function isDisabled(): bool { + return $this->configFactory->get('auto_updates.settings')->get('cron') === static::DISABLED; + } + /** * Generates a log message from a stage validation exception. * diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php index 4fa4e3fc5c84..53467824924c 100644 --- a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php +++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php @@ -2,9 +2,9 @@ namespace Drupal\auto_updates\Event; -use Drupal\auto_updates\Updater; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Stage; use Drupal\package_manager\ValidationResult; /** @@ -31,14 +31,14 @@ class ReadinessCheckEvent extends PreOperationStageEvent { /** * Constructs a ReadinessCheckEvent object. * - * @param \Drupal\auto_updates\Updater $updater - * The updater service. + * @param \Drupal\package_manager\Stage $stage + * The stage service. * @param string[] $project_versions * (optional) The versions of the packages to update to, keyed by package * name. */ - public function __construct(Updater $updater, array $project_versions = []) { - parent::__construct($updater); + public function __construct(Stage $stage, array $project_versions = []) { + parent::__construct($stage); if ($project_versions) { if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) { throw new \InvalidArgumentException("Currently only updates to Drupal core are supported."); @@ -66,7 +66,7 @@ public function getPackageVersions(): array { /** * Adds warning information to the event. */ - public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL) { + public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void { $this->results[] = ValidationResult::createWarning($messages, $summary); } diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php index ad8915a19b3a..7765a16f82b5 100644 --- a/core/modules/auto_updates/src/Form/UpdateReady.php +++ b/core/modules/auto_updates/src/Form/UpdateReady.php @@ -11,6 +11,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Exception\StageException; use Drupal\package_manager\Exception\StageOwnershipException; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -104,46 +105,71 @@ public function buildForm(array $form, FormStateInterface $form_state, string $s return $form; } - // Don't check for pending database updates if the form has been submitted, - // because we don't want to store the warning in the messenger during form - // submit. + $messages = []; + + // If there are any installed modules with database updates in the staging + // area, warn the user that they might be sent to update.php once the + // staged changes have been applied. + $pending_updates = $this->getModulesWithStagedDatabaseUpdates(); + if ($pending_updates) { + $messages[MessengerInterface::TYPE_WARNING][] = $this->t('Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.'); + foreach ($pending_updates as $info) { + $messages[MessengerInterface::TYPE_WARNING][] = $info['name']; + } + } + + try { + $staged_core_packages = $this->updater->getStageComposer() + ->getCorePackages(); + } + catch (\Throwable $exception) { + $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()) { - // If there are any installed modules with database updates in the staging - // area, warn the user that they might be sent to update.php once the - // staged changes have been applied. - $pending_updates = $this->getModulesWithStagedDatabaseUpdates(); - - if ($pending_updates) { - $this->messenger()->addWarning($this->t('Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.')); - foreach ($pending_updates as $info) { - $this->messenger()->addWarning($info['name']); + 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['update_version'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('Drupal core will be updated to %version', [ + '%version' => reset($staged_core_packages)->getPrettyVersion(), + ]), + ]; $form['backup'] = [ '#prefix' => '<strong>', '#markup' => $this->t('Back up your database and site before you continue. <a href=":backup_url">Learn how</a>.', [':backup_url' => 'https://www.drupal.org/node/22281']), '#suffix' => '</strong>', ]; - $form['maintenance_mode'] = [ '#title' => $this->t('Perform updates with site in maintenance mode (strongly recommended)'), '#type' => 'checkbox', '#default_value' => TRUE, ]; - - $form['actions'] = ['#type' => 'actions']; - $form['actions']['cancel'] = [ - '#type' => 'submit', - '#value' => $this->t('Cancel update'), - '#submit' => ['::cancel'], - ]; $form['actions']['submit'] = [ '#type' => 'submit', '#value' => $this->t('Continue'), @@ -170,12 +196,13 @@ protected function getModulesWithStagedDatabaseUpdates(): array { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $session = $this->getRequest()->getSession(); // Store maintenance_mode setting so we can restore it when done. - $session->set('maintenance_mode', $this->state->get('system.maintenance_mode')); - if ($form_state->getValue('maintenance_mode') === TRUE) { + $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); - // @todo unset after updater. After db update? } $stage_id = $form_state->getValue('stage_id'); $batch = (new BatchBuilder()) @@ -193,9 +220,14 @@ public function submitForm(array &$form, FormStateInterface $form_state) { * Cancels the in-progress update. */ public function cancel(array &$form, FormStateInterface $form_state): void { - $this->updater->destroy(); - $this->messenger()->addStatus($this->t('The update was successfully cancelled.')); - $form_state->setRedirect('auto_updates.report_update'); + try { + $this->updater->destroy(); + $this->messenger()->addStatus($this->t('The update was successfully cancelled.')); + $form_state->setRedirect('auto_updates.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 index 7d66c169a67d..90074c6c5de3 100644 --- a/core/modules/auto_updates/src/Form/UpdaterForm.php +++ b/core/modules/auto_updates/src/Form/UpdaterForm.php @@ -4,16 +4,18 @@ use Drupal\auto_updates\BatchProcessor; use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\ProjectInfo; +use Drupal\auto_updates\ReleaseChooser; use Drupal\auto_updates\Updater; -use Drupal\auto_updates\UpdateRecommender; use Drupal\auto_updates\Validation\ReadinessTrait; -use Drupal\auto_updates\Validation\ReadinessValidationManager; use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Link; use Drupal\Core\State\StateInterface; use Drupal\Core\Url; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; use Drupal\system\SystemManager; use Drupal\update\UpdateManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -44,18 +46,18 @@ class UpdaterForm extends FormBase { protected $state; /** - * The readiness validation manager service. + * The event dispatcher service. * - * @var \Drupal\auto_updates\Validation\ReadinessValidationManager + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */ - protected $readinessValidationManager; + protected $eventDispatcher; /** - * The event dispatcher service. + * The release chooser service. * - * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + * @var \Drupal\auto_updates\ReleaseChooser */ - protected $eventDispatcher; + protected $releaseChooser; /** * Constructs a new UpdaterForm object. @@ -64,16 +66,16 @@ class UpdaterForm extends FormBase { * The state service. * @param \Drupal\auto_updates\Updater $updater * The updater service. - * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_validation_manager - * The readiness validation manager service. * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. + * @param \Drupal\auto_updates\ReleaseChooser $release_chooser + * The release chooser service. */ - public function __construct(StateInterface $state, Updater $updater, ReadinessValidationManager $readiness_validation_manager, EventDispatcherInterface $event_dispatcher) { + public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser) { $this->updater = $updater; $this->state = $state; - $this->readinessValidationManager = $readiness_validation_manager; $this->eventDispatcher = $event_dispatcher; + $this->releaseChooser = $release_chooser; } /** @@ -90,8 +92,8 @@ public static function create(ContainerInterface $container) { return new static( $container->get('state'), $container->get('auto_updates.updater'), - $container->get('auto_updates.readiness_validation_manager'), - $container->get('event_dispatcher') + $container->get('event_dispatcher'), + $container->get('auto_updates.release_chooser') ); } @@ -101,14 +103,52 @@ public static function create(ContainerInterface $container) { public function buildForm(array $form, FormStateInterface $form_state) { $this->messenger()->addWarning($this->t('This is an experimental Automatic Updater using Composer. Use at your own risk.')); + if ($this->updater->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->updater->claim($stage_id); + return $this->redirect('auto_updates.confirmation_page', [ + 'stage_id' => $stage_id, + ]); + } + catch (StageOwnershipException $e) { + // 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(); - $recommender = new UpdateRecommender(); try { - $recommended_release = $recommender->getRecommendedRelease(TRUE); + // @todo Until https://www.drupal.org/i/3264849 is fixed, we can only show + // one release on the form. First, try to show the latest release in the + // currently installed minor. Failing that, try to show the latest + // release in the next minor. If neither of those are available, just + // show the first available release. + $recommended_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor(); + if (!$recommended_release) { + $recommended_release = $this->releaseChooser->getLatestInNextMinor(); + if (!$recommended_release) { + // @todo Do not list an update that can't be validated in + // https://www.drupal.org/i/3271235. + $updates = $project_info->getInstallableReleases(); + $recommended_release = array_pop($updates); + } + } } catch (\RuntimeException $e) { $form['message'] = [ @@ -122,6 +162,9 @@ public function buildForm(array $form, FormStateInterface $form_state) { // If we're already up-to-date, there's nothing else we need to do. if ($recommended_release === NULL) { + // @todo Link to the Available Updates report if there are other updates + // that are not supported by this module in + // https://www.drupal.org/i/3271235. $this->messenger()->addMessage('No update available'); return $form; } @@ -133,7 +176,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ], ]; - $project = $recommender->getProjectInfo(); + $project = $project_info->getProjectInfo(); if (empty($project['title']) || empty($project['link'])) { throw new \UnexpectedValueException('Expected project data to have a title and link.'); } @@ -161,7 +204,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { 'title' => [ 'data' => $title, ], - 'installed_version' => $project['existing_version'], + 'installed_version' => $project_info->getInstalledVersion(), 'recommended_version' => [ 'data' => [ // @todo Is an inline template the right tool here? Is there an Update @@ -210,53 +253,42 @@ public function buildForm(array $form, FormStateInterface $form_state) { } $this->displayResults($results, $this->messenger()); - // If there were no errors, allow the user to proceed with the update. - if ($this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR) { - $form['actions'] = $this->actions($form_state); - } - return $form; - } - - /** - * Builds the form actions. - * - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The form state. - * - * @return mixed[][] - * The form's actions elements. - */ - protected function actions(FormStateInterface $form_state): array { - $actions = ['#type' => 'actions']; - - if (!$this->updater->isAvailable()) { - // If the form has been submitted do not display this error message + 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.')); } - $actions['delete'] = [ + $form['actions']['delete'] = [ '#type' => 'submit', '#value' => $this->t('Delete existing update'), '#submit' => ['::deleteExistingUpdate'], ]; } - else { - $actions['submit'] = [ + // If there were no errors, allow the user to proceed with the update. + elseif ($this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR) { + $form['actions']['submit'] = [ '#type' => 'submit', '#value' => $this->t('Update'), ]; } - return $actions; + $form['actions']['#type'] = 'actions'; + + return $form; } /** * Submit function to delete an existing in-progress update. */ public function deleteExistingUpdate(): void { - $this->updater->destroy(TRUE); - $this->messenger()->addMessage($this->t("Staged update deleted")); + try { + $this->updater->destroy(TRUE); + $this->messenger()->addMessage($this->t("Staged update deleted")); + } + catch (StageException $e) { + $this->messenger()->addError($e->getMessage()); + } } /** diff --git a/core/modules/auto_updates/src/ProjectInfo.php b/core/modules/auto_updates/src/ProjectInfo.php new file mode 100644 index 000000000000..df802a80d906 --- /dev/null +++ b/core/modules/auto_updates/src/ProjectInfo.php @@ -0,0 +1,106 @@ +<?php + +namespace Drupal\auto_updates; + +use Composer\Semver\Comparator; +use Composer\Semver\Semver; +use Drupal\update\ProjectRelease; +use Drupal\update\UpdateManagerInterface; + +/** + * Defines a class for retrieving project information from Update module. + * + * @todo Allow passing a project name to handle more than Drupal core in + * https://www.drupal.org/i/3271240. + */ +class ProjectInfo { + + /** + * Returns up-to-date project information for Drupal core. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return array + * The retrieved project information for Drupal core. + * + * @throws \RuntimeException + * If data about available updates cannot be retrieved. + */ + public function getProjectInfo(bool $refresh = FALSE): array { + $available_updates = update_get_available($refresh); + $project_data = update_calculate_project_data($available_updates); + return $project_data['drupal']; + } + + /** + * Gets all releases of Drupal core to which the site can update. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return \Drupal\update\ProjectRelease[] + * An array of possible update releases with release versions as keys. The + * releases are in descending order by version number (i.e., higher versions + * are listed first). + * + * @throws \RuntimeException + * Thrown if $refresh is TRUE and there are no available releases. + * + * @todo Remove or simplify this function in https://www.drupal.org/i/3252190. + */ + public function getInstallableReleases(bool $refresh = FALSE): array { + $project = $this->getProjectInfo($refresh); + $installed_version = $this->getInstalledVersion(); + // If we refreshed and we were able to get available releases we should + // always have at least have the current release stored. + if ($refresh && empty($project['releases'])) { + throw new \RuntimeException('There was a problem getting update information. Try again later.'); + } + // If we're already up-to-date, there's nothing else we need to do. + if ($project['status'] === UpdateManagerInterface::CURRENT) { + return []; + } + elseif (empty($project['recommended'])) { + // If we don't know what to recommend they update to, time to freak out. + throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.'); + } + $installable_releases = []; + if (Comparator::greaterThan($project['recommended'], $installed_version)) { + $release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]); + $installable_releases[$release->getVersion()] = $release; + } + if (!empty($project['security updates'])) { + foreach ($project['security updates'] as $security_update) { + $release = ProjectRelease::createFromArray($security_update); + $version = $release->getVersion(); + if (Comparator::greaterThan($version, $installed_version)) { + $installable_releases[$version] = $release; + } + } + } + $sorted_versions = Semver::rsort(array_keys($installable_releases)); + return array_replace(array_flip($sorted_versions), $installable_releases); + } + + /** + * Returns the installed project version, according to the Update module. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return string + * The installed project version as known to the Update module. + */ + public function getInstalledVersion(bool $refresh = FALSE): string { + $project_data = $this->getProjectInfo($refresh); + return $project_data['existing_version']; + } + +} diff --git a/core/modules/auto_updates/src/ReleaseChooser.php b/core/modules/auto_updates/src/ReleaseChooser.php new file mode 100644 index 000000000000..c72ec3a572e9 --- /dev/null +++ b/core/modules/auto_updates/src/ReleaseChooser.php @@ -0,0 +1,132 @@ +<?php + +namespace Drupal\auto_updates; + +use Composer\Semver\Semver; +use Drupal\auto_updates\Validator\UpdateVersionValidator; +use Drupal\update\ProjectRelease; +use Drupal\Core\Extension\ExtensionVersion; + +/** + * Defines a class to choose a release of Drupal core to update to. + */ +class ReleaseChooser { + + use VersionParsingTrait; + + /** + * The version validator service. + * + * @var \Drupal\auto_updates\Validator\UpdateVersionValidator + */ + protected $versionValidator; + + /** + * The project information fetcher. + * + * @var \Drupal\auto_updates\ProjectInfo + */ + protected $projectInfo; + + /** + * Constructs an ReleaseChooser object. + * + * @param \Drupal\auto_updates\Validator\UpdateVersionValidator $version_validator + * The version validator. + */ + public function __construct(UpdateVersionValidator $version_validator) { + $this->versionValidator = $version_validator; + $this->projectInfo = new ProjectInfo(); + } + + /** + * Refreshes the project information through the Update module. + * + * @return $this + * The called object. + */ + public function refresh(): self { + $this->projectInfo->getProjectInfo(TRUE); + return $this; + } + + /** + * Returns the releases that are installable. + * + * @return \Drupal\update\ProjectRelease[] + * The releases that are installable according to the version validator + * service. + */ + protected function getInstallableReleases(): array { + return array_filter( + $this->projectInfo->getInstallableReleases(), + [$this->versionValidator, 'isValidVersion'], + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Gets the most recent release in the same minor as a specified version. + * + * @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. + */ + protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease { + if (static::getPatchVersion($version) === NULL) { + throw new \InvalidArgumentException("The version number $version does not contain a patch version"); + } + $releases = $this->getInstallableReleases(); + foreach ($releases as $release) { + 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. + */ + protected 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. + * + * @return \Drupal\update\ProjectRelease|null + * The latest release in the currently installed minor, if any, otherwise + * NULL. + */ + public function getLatestInInstalledMinor(): ?ProjectRelease { + return $this->getMostRecentReleaseInMinor($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. + * + * @return \Drupal\update\ProjectRelease|null + * The latest release in the next minor, if any, otherwise NULL. + */ + public function getLatestInNextMinor(): ?ProjectRelease { + $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion()); + $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0'; + return $this->getMostRecentReleaseInMinor($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..bbcad03e9443 --- /dev/null +++ b/core/modules/auto_updates/src/Routing/RouteSubscriber.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\auto_updates\Routing; + +use Drupal\Core\Routing\RouteSubscriberBase; +use Symfony\Component\Routing\RouteCollection; + +/** + * Modifies route definitions. + * + * @internal + */ +class RouteSubscriber extends RouteSubscriberBase { + + /** + * {@inheritdoc} + */ + protected function alterRoutes(RouteCollection $collection) { + $disabled_routes = [ + 'update.theme_update', + 'system.theme_install', + 'update.module_update', + 'update.module_install', + 'update.status', + 'update.report_update', + 'update.report_install', + 'update.settings', + 'system.status', + 'update.confirmation_page', + ]; + foreach ($disabled_routes as $route) { + $route = $collection->get($route); + if ($route) { + $route->setOption('_auto_updates_readiness_messages', 'skip'); + } + } + } + +} diff --git a/core/modules/auto_updates/src/UpdateRecommender.php b/core/modules/auto_updates/src/UpdateRecommender.php deleted file mode 100644 index adf09b2ee1f1..000000000000 --- a/core/modules/auto_updates/src/UpdateRecommender.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php - -namespace Drupal\auto_updates; - -use Drupal\update\ProjectRelease; -use Drupal\update\UpdateManagerInterface; - -/** - * Determines the recommended release of Drupal core to update to. - */ -class UpdateRecommender { - - /** - * Returns up-to-date project information for Drupal core. - * - * @param bool $refresh - * (optional) Whether to fetch the latest information about available - * updates from drupal.org. This can be an expensive operation, so defaults - * to FALSE. - * - * @return array - * The retrieved project information for Drupal core. - * - * @throws \RuntimeException - * If data about available updates cannot be retrieved. - */ - public function getProjectInfo(bool $refresh = FALSE): array { - $available_updates = update_get_available($refresh); - if (empty($available_updates)) { - throw new \RuntimeException('There was a problem getting update information. Try again later.'); - } - - $project_data = update_calculate_project_data($available_updates); - return $project_data['drupal']; - } - - /** - * Returns the recommended release of Drupal core. - * - * @param bool $refresh - * (optional) Whether to fetch the latest information about available - * updates from drupal.org. This can be an expensive operation, so defaults - * to FALSE. - * - * @return \Drupal\update\ProjectRelease|null - * A value object with information about the recommended release, or NULL - * if Drupal core is already up-to-date. - * - * @throws \LogicException - * If Drupal core is out of date and the recommended version of cannot be - * determined. - */ - public function getRecommendedRelease(bool $refresh = FALSE): ?ProjectRelease { - $project = $this->getProjectInfo($refresh); - - // If we're already up-to-date, there's nothing else we need to do. - if ($project['status'] === UpdateManagerInterface::CURRENT) { - return NULL; - } - // If we don't know what to recommend they update to, time to freak out. - elseif (empty($project['recommended'])) { - throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.'); - } - - $recommended_version = $project['recommended']; - return ProjectRelease::createFromArray($project['releases'][$recommended_version]); - } - -} diff --git a/core/modules/auto_updates/src/Updater.php b/core/modules/auto_updates/src/Updater.php index b9c964a0ec0a..68b3aec8564e 100644 --- a/core/modules/auto_updates/src/Updater.php +++ b/core/modules/auto_updates/src/Updater.php @@ -91,9 +91,9 @@ public function stage(): void { /** * {@inheritdoc} */ - protected function dispatch(StageEvent $event): void { + protected function dispatch(StageEvent $event, callable $on_error = NULL): void { try { - parent::dispatch($event); + parent::dispatch($event, $on_error); } catch (StageValidationException $e) { throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e); diff --git a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php index b644d39a668c..b8d4cb767145 100644 --- a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php +++ b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php @@ -2,6 +2,8 @@ namespace Drupal\auto_updates\Validation; +use Drupal\auto_updates\CronUpdater; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Messenger\MessengerTrait; @@ -57,6 +59,13 @@ final class AdminReadinessMessages implements ContainerInjectionInterface { */ protected $currentRouteMatch; + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $config; + /** * Constructs a ReadinessRequirement object. * @@ -72,14 +81,17 @@ final class AdminReadinessMessages implements ContainerInjectionInterface { * The translation service. * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match * The current route match. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config + * The config factory service. */ - public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match) { + public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match, ConfigFactoryInterface $config) { $this->readinessCheckerManager = $readiness_checker_manager; $this->setMessenger($messenger); $this->adminContext = $admin_context; $this->currentUser = $current_user; $this->setStringTranslation($translation); $this->currentRouteMatch = $current_route_match; + $this->config = $config; } /** @@ -92,7 +104,8 @@ public static function create(ContainerInterface $container): self { $container->get('router.admin_context'), $container->get('current_user'), $container->get('string_translation'), - $container->get('current_route_match') + $container->get('current_route_match'), + $container->get('config.factory') ); } @@ -127,24 +140,15 @@ public function displayAdminPageMessages(): void { * Whether the messages should be displayed on the current page. */ protected function displayResultsOnCurrentPage(): bool { + // If updates will not run during cron then we don't need to show the + // readiness checks on admin pages. + if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) { + return FALSE; + } + if ($this->adminContext->isAdminRoute() && $this->currentUser->hasPermission('administer site configuration')) { - // These routes don't need additional nagging. - $disabled_routes = [ - 'update.theme_update', - 'system.theme_install', - 'update.module_update', - 'update.module_install', - 'update.status', - 'update.report_update', - 'update.report_install', - 'update.settings', - 'system.status', - 'update.confirmation_page', - 'auto_updates.report_update', - 'auto_updates.module_update', - 'auto_updates.theme_update', - ]; - return !in_array($this->currentRouteMatch->getRouteName(), $disabled_routes, TRUE); + $route = $this->currentRouteMatch->getRouteObject(); + return $route && $route->getOption('_auto_updates_readiness_messages') !== 'skip'; } return FALSE; } diff --git a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php index 8c71360b9da9..c513af498f8f 100644 --- a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php +++ b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php @@ -4,8 +4,8 @@ use Drupal\auto_updates\CronUpdater; use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\ProjectInfo; use Drupal\auto_updates\Updater; -use Drupal\auto_updates\UpdateRecommender; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; @@ -102,20 +102,18 @@ public function __construct(KeyValueExpirableFactoryInterface $key_value_expirab * @return $this */ public function run(): self { - $recommender = new UpdateRecommender(); - $release = $recommender->getRecommendedRelease(TRUE); // If updates will run during cron, use the cron updater service provided by // this module. This will allow subscribers to ReadinessCheckEvent to run // specific validation for conditions that only affect cron updates. - if ($this->config->get('auto_updates.settings')->get('cron') == CronUpdater::DISABLED) { + if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) { $stage = $this->updater; } else { $stage = $this->cronUpdater; } - - $project_versions = $release ? ['drupal' => $release->getVersion()] : []; - $event = new ReadinessCheckEvent($stage, $project_versions); + $event = new ReadinessCheckEvent($stage); + // Version validators will need up-to-date project info. + (new ProjectInfo())->getProjectInfo(TRUE); $this->eventDispatcher->dispatch($event); $results = $event->getResults(); $this->keyValueExpirable->setWithExpire( diff --git a/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php new file mode 100644 index 000000000000..de52a4ad0fa2 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php @@ -0,0 +1,100 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\CronUpdater; +use Drupal\auto_updates\ProjectInfo; +use Drupal\auto_updates\VersionParsingTrait; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\package_manager\Stage; +use Drupal\package_manager\ValidationResult; + +/** + * Validates the target version of Drupal core before a cron update. + * + * @internal + * This class is an internal part of the module's cron update handling and + * should not be used by external code. + */ +final class CronUpdateVersionValidator extends UpdateVersionValidator { + + use VersionParsingTrait; + + /** + * {@inheritdoc} + */ + protected static function isStageSupported(Stage $stage): bool { + return $stage instanceof CronUpdater; + } + + /** + * {@inheritdoc} + */ + public function getValidationResult(string $to_version_string): ?ValidationResult { + if ($result = parent::getValidationResult($to_version_string)) { + return $result; + } + $from_version_string = $this->getCoreVersion(); + $to_version = ExtensionVersion::createFromVersionString($to_version_string); + $from_version = ExtensionVersion::createFromVersionString($from_version_string); + $variables = [ + '@to_version' => $to_version_string, + '@from_version' => $from_version_string, + ]; + // @todo Return multiple validation messages and summary in + // https://www.drupal.org/project/auto_updates/issues/3272068. + // Validate that both the from and to versions are stable releases. + if ($from_version->getVersionExtra()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables), + ]); + } + if ($to_version->getVersionExtra()) { + // Because we do not support updating to a new minor version during + // cron it is probably impossible to update from a stable version to + // a unstable/pre-release version, but we should check this condition + // just in case. + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables), + ]); + } + + if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables), + ]); + } + + // Only updating to the next patch release is supported during cron. + $supported_patch_version = $from_version->getMajorVersion() . '.' . $from_version->getMinorVersion() . '.' . (((int) static::getPatchVersion($from_version_string)) + 1); + if ($to_version_string !== $supported_patch_version) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables), + ]); + } + + // If both the from and to version numbers are valid check if the current + // settings only allow security updates during cron and if so ensure the + // update release is a security release. + $level = $this->configFactory->get('auto_updates.settings')->get('cron'); + if ($level === CronUpdater::SECURITY) { + $releases = (new ProjectInfo())->getInstallableReleases(); + // @todo Remove this check and add validation to + // \Drupal\auto_updates\Validator\UpdateVersionValidator::getValidationResult() + // to ensure the update release is always secure and supported in + // https://www.drupal.org/i/3271468. + if (!isset($releases[$to_version_string])) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a valid release.', $variables), + ]); + } + if (!$releases[$to_version_string]->isSecurityRelease()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a security release.', $variables), + ]); + } + } + return NULL; + } + +} diff --git a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php index f7d5751df259..9ff9ea6e7f79 100644 --- a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php +++ b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php @@ -2,20 +2,28 @@ namespace Drupal\auto_updates\Validator; -use Composer\Semver\Semver; +use Composer\Semver\Comparator; use Drupal\auto_updates\CronUpdater; use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\ProjectInfo; use Drupal\auto_updates\Updater; use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\package_manager\Event\PreCreateEvent; -use Drupal\package_manager\Event\PreOperationStageEvent; use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Stage; +use Drupal\package_manager\ValidationResult; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** * Validates that core updates are within a supported version range. + * + * @internal + * This class is an internal part of the module's update handling and + * should not be used by external code. */ class UpdateVersionValidator implements EventSubscriberInterface { @@ -48,12 +56,7 @@ public function __construct(TranslationInterface $translation, ConfigFactoryInte * The running core version as known to the Update module. */ protected function getCoreVersion(): string { - // We need to call these functions separately, because - // update_get_available() will include the file that contains - // update_calculate_project_data(). - $available_updates = update_get_available(); - $available_updates = update_calculate_project_data($available_updates); - return $available_updates['drupal']['existing_version']; + return (new ProjectInfo())->getInstalledVersion(); } /** @@ -63,122 +66,141 @@ protected function getCoreVersion(): string { * The event object. */ public function checkUpdateVersion(PreOperationStageEvent $event): void { - $stage = $event->getStage(); - // We only want to do this check if the stage belongs to Automatic Updates. - if (!$stage instanceof Updater) { + if (!static::isStageSupported($event->getStage())) { return; } + if ($to_version = $this->getUpdateVersion($event)) { + if ($result = $this->getValidationResult($to_version)) { + $event->addError($result->getMessages(), $result->getSummary()); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'checkUpdateVersion', + ReadinessCheckEvent::class => 'checkUpdateVersion', + ]; + } + /** + * Gets the update version. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event. + * + * @return string|null + * The version that the site will update to if any, otherwise NULL. + */ + protected function getUpdateVersion(StageEvent $event): ?string { + /** @var \Drupal\auto_updates\Updater $updater */ + $updater = $event->getStage(); if ($event instanceof ReadinessCheckEvent) { $package_versions = $event->getPackageVersions(); - // During readiness checks, we might not know the desired package - // versions, which means there's nothing to validate. - if (empty($package_versions)) { - return; - } } else { // If the stage has begun its life cycle, we expect it knows the desired // package versions. - $package_versions = $stage->getPackageVersions()['production']; + $package_versions = $updater->getPackageVersions()['production']; + } + if ($package_versions) { + // All the core packages will be updated to the same version, so it + // doesn't matter which specific package we're looking at. + $core_package_name = key($updater->getActiveComposer()->getCorePackages()); + return $package_versions[$core_package_name]; + } + else { + // During readiness checks we might not have a version to update to. Check + // if there are any possible updates and add a message about why we cannot + // update to that version. + // @todo Remove this code in https://www.drupal.org/i/3272326 when we add + // add a validator that will warn if cron updates will no longer work + // because the site is more than 1 patch release behind. + $project_info = new ProjectInfo(); + if ($possible_releases = $project_info->getInstallableReleases()) { + $possible_release = array_pop($possible_releases); + return $possible_release->getVersion(); + } } + return NULL; + } + /** + * Determines if a version is valid. + * + * @param string $version + * The version string. + * + * @return bool + * TRUE if the version is valid (i.e., the site can update to it), otherwise + * FALSE. + */ + public function isValidVersion(string $version): bool { + return empty($this->getValidationResult($version)); + } + + /** + * Validates if an update to a specific version is allowed. + * + * @param string $to_version_string + * The version to update to. + * + * @return \Drupal\package_manager\ValidationResult|null + * NULL if the update is allowed, otherwise returns a validation result with + * the reason why the update is not allowed. + */ + protected function getValidationResult(string $to_version_string): ?ValidationResult { $from_version_string = $this->getCoreVersion(); - $from_version = ExtensionVersion::createFromVersionString($from_version_string); - // All the core packages will be updated to the same version, so it doesn't - // matter which specific package we're looking at. - $core_package_name = key($stage->getActiveComposer()->getCorePackages()); - $to_version_string = $package_versions[$core_package_name]; - $to_version = ExtensionVersion::createFromVersionString($to_version_string); $variables = [ '@to_version' => $to_version_string, '@from_version' => $from_version_string, ]; - $from_version_extra = $from_version->getVersionExtra(); - $to_version_extra = $to_version->getVersionExtra(); - if (Semver::satisfies($to_version_string, "< $from_version_string")) { - $event->addError([ - $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables), + $from_version = ExtensionVersion::createFromVersionString($from_version_string); + // @todo Return multiple validation messages and summary in + // https://www.drupal.org/project/auto_updates/issues/3272068. + if ($from_version->getVersionExtra() === 'dev') { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables), ]); } - elseif ($from_version_extra === 'dev') { - $event->addError([ - $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables), + if (Comparator::lessThan($to_version_string, $from_version_string)) { + return ValidationResult::createError([ + $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables), ]); } - elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { - $event->addError([ + $to_version = ExtensionVersion::createFromVersionString($to_version_string); + if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { + return ValidationResult::createError([ $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one major version to another are not supported.', $variables), ]); } - elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { + if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { if (!$this->configFactory->get('auto_updates.settings')->get('allow_core_minor_updates')) { - $event->addError([ + return ValidationResult::createError([ $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported.', $variables), ]); } - elseif ($stage instanceof CronUpdater) { - $event->addError([ - $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables), - ]); - } - } - elseif ($stage instanceof CronUpdater) { - if ($from_version_extra || $to_version_extra) { - if ($from_version_extra) { - $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables); - $event->addError($messages); - } - if ($to_version_extra) { - // Because we do not support updating to a new minor version during - // cron it is probably impossible to update from a stable version to - // a unstable/pre-release version, but we should check this condition - // just in case. - $messages[] = $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables); - $event->addError($messages); - } - } - else { - $to_patch_version = (int) $this->getPatchVersion($to_version_string); - $from_patch_version = (int) $this->getPatchVersion($from_version_string); - if ($from_patch_version + 1 !== $to_patch_version) { - $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables); - $event->addError($messages); - } - } } + return NULL; } /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - return [ - PreCreateEvent::class => 'checkUpdateVersion', - ReadinessCheckEvent::class => 'checkUpdateVersion', - ]; - } - - /** - * Gets the patch number for a version string. + * Determines if a stage is supported by this validator. * - * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in - * https://www.drupal.org/i/3261744. + * @param \Drupal\package_manager\Stage $stage + * The stage to check. * - * @param string $version_string - * The version string. - * - * @return string - * The patch number. + * @return bool + * TRUE if the stage is supported by this validator, otherwise FALSE. */ - private 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 $version_parts[2]; + protected static function isStageSupported(Stage $stage): bool { + // We only want to do this check if the stage belongs to Automatic Updates, + // and it is not a cron update. + // @see \Drupal\auto_updates\Validator\CronUpdateVersionValidator + return $stage instanceof Updater && !$stage instanceof CronUpdater; } } diff --git a/core/modules/auto_updates/src/VersionParsingTrait.php b/core/modules/auto_updates/src/VersionParsingTrait.php new file mode 100644 index 000000000000..c94eb113043e --- /dev/null +++ b/core/modules/auto_updates/src/VersionParsingTrait.php @@ -0,0 +1,37 @@ +<?php + +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. + */ + protected 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; + } + +} diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml new file mode 100644 index 000000000000..cc65b78a690d --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml @@ -0,0 +1,102 @@ +<?xml version="1.0" encoding="utf-8"?> +<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.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> +</releases> +</project> diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml index 7bb2c1a1128a..2de1077e5e2d 100644 --- a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml +++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml @@ -3,7 +3,7 @@ <title>Drupal</title> <short_name>drupal</short_name> <dc:creator>Drupal</dc:creator> -<supported_branches>9.8.</supported_branches> +<supported_branches>9.7.,9.8.</supported_branches> <project_status>published</project_status> <link>http://example.com/project/drupal</link> <terms> @@ -58,5 +58,41 @@ <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> </releases> </project> diff --git a/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml b/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml new file mode 100644 index 000000000000..cdb353fd4208 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml @@ -0,0 +1,184 @@ +<?xml version="1.0" encoding="utf-8"?> +<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> + <!-- This release is not in a supported branch; therefore it should not be recommended. --> + <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/auto_updates/tests/fixtures/staged/9.8.1/composer.json b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json new file mode 100644 index 000000000000..6a9ed719d00a --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json @@ -0,0 +1,7 @@ +{ + "extra": { + "_readme": [ + "This fixture simulates a staging area in which, according to Composer, Drupal core 9.8.1 is installed." + ] + } +} diff --git a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json new file mode 100644 index 000000000000..87b5a18421ce --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json @@ -0,0 +1,9 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + } + ] +} 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..e0792445e68d --- /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_9001. + */ + function auto_updates_update_9001(&$sandbox) { + + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml index 8c5c2a6988f0..f22ab077fb3b 100644 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml @@ -5,9 +5,13 @@ auto_updates_test.metadata: _controller: '\Drupal\auto_updates_test\TestController::metadata' requirements: _access: 'TRUE' + options: + _maintenance_access: TRUE auto_updates_test.update: path: '/automatic-update-test/update/{to_version}' defaults: _controller: '\Drupal\auto_updates_test\TestController::update' requirements: _access: 'TRUE' + options: + _maintenance_access: TRUE 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 index ec89173182d4..c3c46dc8d051 100644 --- 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 @@ -1,8 +1,4 @@ services: - auto_updates_test.route_subscriber: - class: \Drupal\auto_updates_test\Routing\RouteSubscriber - tags: - - { name: event_subscriber } auto_updates_test.checker: class: Drupal\auto_updates_test\EventSubscriber\TestSubscriber1 tags: diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php deleted file mode 100644 index 9be0b1e42242..000000000000 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php +++ /dev/null @@ -1,24 +0,0 @@ -<?php - -namespace Drupal\auto_updates_test; - -use Drupal\auto_updates_test\Validator\TestPendingUpdatesValidator; -use Drupal\Core\DependencyInjection\ContainerBuilder; -use Drupal\Core\DependencyInjection\ServiceProviderBase; - -/** - * Modifies container services for testing purposes. - */ -class AutoUpdatesTestServiceProvider extends ServiceProviderBase { - - /** - * {@inheritdoc} - */ - public function alter(ContainerBuilder $container) { - parent::alter($container); - - $container->getDefinition('package_manager.validator.pending_updates') - ->setClass(TestPendingUpdatesValidator::class); - } - -} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php deleted file mode 100644 index 7953980da788..000000000000 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -namespace Drupal\auto_updates_test\Form; - -use Drupal\auto_updates\Form\UpdateReady; - -/** - * A test-only version of the form displayed before applying an update. - */ -class TestUpdateReady extends UpdateReady { - - /** - * {@inheritdoc} - */ - protected function getModulesWithStagedDatabaseUpdates(): array { - return $this->state->get('auto_updates_test.staged_database_updates', parent::getModulesWithStagedDatabaseUpdates()); - } - -} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php deleted file mode 100644 index f12aad83749a..000000000000 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -namespace Drupal\auto_updates_test\Routing; - -use Drupal\auto_updates_test\Form\TestUpdateReady; -use Drupal\Core\Routing\RouteSubscriberBase; -use Symfony\Component\Routing\RouteCollection; - -/** - * Alters route definitions for testing purposes. - */ -class RouteSubscriber extends RouteSubscriberBase { - - /** - * {@inheritdoc} - */ - protected function alterRoutes(RouteCollection $collection) { - $collection->get('auto_updates.confirmation_page') - ->setDefault('_form', TestUpdateReady::class); - } - -} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php index 3ad87828be6f..7342479e95bd 100644 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php @@ -3,7 +3,6 @@ namespace Drupal\auto_updates_test; use Drupal\auto_updates\Exception\UpdateException; -use Drupal\auto_updates\UpdateRecommender; use Drupal\Component\Utility\Environment; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Render\HtmlResponse; @@ -36,8 +35,17 @@ public function update(string $to_version): Response { $updater->apply(); $updater->destroy(); - $project = (new UpdateRecommender())->getProjectInfo(); - $content = $project['existing_version']; + // The code base has been updated, but as far as the PHP runtime is + // concerned, \Drupal::VERSION refers to the old version, until the next + // request. So check if the updated version is in Drupal.php and return + // a clear indication of whether it's there or not. + $drupal_php = file_get_contents(\Drupal::root() . '/core/lib/Drupal.php'); + if (str_contains($drupal_php, "const VERSION = '$to_version';")) { + $content = "$to_version found in Drupal.php"; + } + else { + $content = "$to_version not found in Drupal.php"; + } $status = 200; } catch (UpdateException $e) { @@ -63,9 +71,6 @@ public function update(string $to_version): Response { * directory of mock XML files. */ public function metadata($project_name = 'drupal', $version = NULL): Response { - if ($project_name !== 'drupal') { - return new Response(); - } $xml_map = $this->config('update_test.settings')->get('xml_map'); if (isset($xml_map[$project_name])) { $availability_scenario = $xml_map[$project_name]; diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php deleted file mode 100644 index 8f735f95d4cb..000000000000 --- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php +++ /dev/null @@ -1,30 +0,0 @@ -<?php - -namespace Drupal\auto_updates_test\Validator; - -use Drupal\package_manager\Validator\PendingUpdatesValidator; - -/** - * Defines a test-only implementation of the pending updates validator. - */ -class TestPendingUpdatesValidator extends PendingUpdatesValidator { - - /** - * {@inheritdoc} - */ - public function updatesExist(): bool { - $pending_updates = \Drupal::state() - ->get('auto_updates_test.staged_database_updates', []); - - // If the System module should expose a pending update, create one that will - // be detected by the update hook registry. We only do this for System so - // that there is NO way we could possibly evaluate any user input (i.e., - // if malicious code were somehow injected into state). - if (array_key_exists('system', $pending_updates)) { - // @codingStandardsIgnoreLine - eval('function system_update_4294967294() {}'); - } - return parent::updatesExist(); - } - -} diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php index be3070d61b21..06f149e3b437 100644 --- a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php +++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php @@ -71,8 +71,13 @@ public function testApi(string $template): void { // directories are not writable. $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1'); - $mink->getSession()->reload(); - $assert_session->pageTextContains('9.8.1'); + $session = $mink->getSession(); + $session->reload(); + $this->assertSame('9.8.1 found in Drupal.php', trim($session->getPage()->getContent())); + // 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. + $assert_session->statusCodeEquals(200); + $this->assertUpdateSuccessful('9.8.1'); } /** diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php index db8d2575555a..8f1a0e316faf 100644 --- a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php +++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php @@ -15,6 +15,7 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase { */ protected static $modules = [ 'auto_updates_test_disable_validators', + 'package_manager_bypass', 'update', 'update_test', ]; @@ -32,6 +33,17 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase { // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() 'auto_updates.validator.file_system_permissions', 'package_manager.validator.file_system', + // Disable the Composer executable validator, since it may cause the tests + // to fail if a supported version of Composer is unavailable to the web + // server. This should be okay in most situations because, apart from the + // validator, only Composer Stager needs run Composer, and + // package_manager_bypass is disabling those operations. + 'auto_updates.composer_executable_validator', + 'package_manager.validator.composer_executable', + // Disable the lock file validator, because it may cause the tests to fail + // if either the active and stage directories don't have a composer.lock + // file, which is the case with some of our fixtures. + 'package_manager.validator.lock_file', ]; /** @@ -42,6 +54,19 @@ protected function setUp() { $this->disableValidators($this->disableValidators); } + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // If auto_updates is installed, ensure any staging area created during + // the test is cleaned up. + $service_id = 'auto_updates.updater'; + if ($this->container->has($service_id)) { + $this->container->get($service_id)->destroy(TRUE); + } + parent::tearDown(); + } + /** * Disables validators in the test site's settings. * @@ -116,10 +141,14 @@ protected function checkForUpdates(): void { /** * Asserts that we are on the "update ready" form. + * + * @param string $update_version + * The version of Drupal core that we are updating to. */ - protected function assertUpdateReady(): void { - $this->assertSession() - ->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/'); + protected function assertUpdateReady(string $update_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 ' . $update_version); } } diff --git a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php index ad364fdac6ab..67e4c83ecb0c 100644 --- a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php +++ b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php @@ -7,6 +7,8 @@ use Drupal\auto_updates_test\Datetime\TestTime; use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; use Drupal\auto_updates_test2\EventSubscriber\TestSubscriber2; +use Drupal\Core\Url; +use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager; use Drupal\system\SystemManager; use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; use Drupal\Tests\Traits\Core\CronRunTrait; @@ -52,7 +54,7 @@ class ReadinessValidationTest extends AutoUpdatesFunctionalTestBase { */ protected function setUp(): void { parent::setUp(); - $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); $this->setCoreVersion('9.8.1'); $this->reportViewerUser = $this->createUser([ @@ -63,6 +65,7 @@ protected function setUp(): void { 'administer site configuration', 'administer software updates', 'access administration pages', + 'access site in maintenance mode', ]); $this->createTestValidationResults(); $this->drupalLogin($this->reportViewerUser); @@ -291,6 +294,15 @@ public function testReadinessChecksAdminPages(): void { $assert->pageTextContainsOnce(static::$warningsExplanation); $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); $assert->pageTextNotContains($expected_results[0]->getSummary()); + + // Confirm readiness messages are not displayed when cron updates are + // disabled. + $this->drupalGet(Url::fromRoute('update.settings')); + $edit['auto_updates_cron'] = 'disable'; + $this->submitForm($edit, 'Save configuration'); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains(static::$warningsExplanation); + $assert->pageTextNotContains($expected_results[0]->getMessages()[0]); } /** @@ -387,11 +399,12 @@ public function testStoredResultsClearedAfterUpdate(): void { $this->container->get('module_installer')->install([ 'auto_updates', 'auto_updates_test', - 'package_manager_bypass', + 'package_manager_test_fixture', ]); // Because all actual staging operations are bypassed by - // package_manager_bypass, disable this validator because it will complain - // if there's no actual Composer data to inspect. + // package_manager_bypass (enabled by the parent class), disable this + // validator because it will complain if there's no actual Composer data to + // inspect. $this->disableValidators(['auto_updates.staged_projects_validator']); // The error should be persistently visible, even after the checker stops @@ -407,6 +420,7 @@ public function testStoredResultsClearedAfterUpdate(): void { // readiness check (without storing the results), and the checker is no // longer raising an error. $this->drupalGet('/admin/modules/automatic-update'); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); $assert_session->buttonExists('Update'); // Ensure that the previous results are still displayed on another admin // page, to confirm that the updater form is not discarding the previous @@ -417,7 +431,7 @@ public function testStoredResultsClearedAfterUpdate(): void { $this->drupalGet('/admin/modules/automatic-update'); $page->pressButton('Update'); $this->checkForMetaRefresh(); - $this->assertUpdateReady(); + $this->assertUpdateReady('9.8.1'); $page->pressButton('Continue'); $this->checkForMetaRefresh(); $assert_session->pageTextContains('Update complete!'); diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php index f7b19ffc0f36..f540f47f4493 100644 --- a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php +++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\auto_updates\Functional; +use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager; + /** * Tests that only one Automatic Update operation can be performed at a time. * @@ -20,7 +22,7 @@ class UpdateLockTest extends AutoUpdatesFunctionalTestBase { protected static $modules = [ 'auto_updates', 'auto_updates_test', - 'package_manager_bypass', + 'package_manager_test_fixture', ]; /** @@ -37,7 +39,7 @@ protected function setUp(): void { /** * Tests that only user who started an update can continue through it. */ - public function testLock() { + public function testLock(): void { $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); $this->setCoreVersion('9.8.0'); @@ -49,11 +51,12 @@ public function testLock() { // We should be able to get partway through an update without issue. $this->drupalLogin($user_1); $this->drupalGet('/admin/modules/automatic-update'); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); $page->pressButton('Update'); $this->checkForMetaRefresh(); - $this->assertUpdateReady(); + $this->assertUpdateReady('9.8.1'); $assert_session->buttonExists('Continue'); - $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH); + $url = $this->getSession()->getCurrentUrl(); // Another user cannot show up and try to start an update, since the other // user already started one. diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php index fc892ee15cc6..599f0471ecbc 100644 --- a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php +++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php @@ -3,9 +3,14 @@ namespace Drupal\Tests\auto_updates\Functional; use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates_test\Datetime\TestTime; +use Drupal\Component\FileSystem\FileSystem; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\ValidationResult; use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager; use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; @@ -31,7 +36,7 @@ class UpdaterFormTest extends AutoUpdatesFunctionalTestBase { 'block', 'auto_updates', 'auto_updates_test', - 'package_manager_bypass', + 'package_manager_test_fixture', ]; /** @@ -237,58 +242,137 @@ public function testMinorVersionUpdateNotSupported(string $update_form_url): voi /** * Tests deleting an existing update. - * - * @todo Add test coverage for differences between stage owner and other users - * in https://www.drupal.org/i/3248928. */ - public function testDeleteExistingUpdate() { + public function testDeleteExistingUpdate(): void { + $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->setCoreVersion('9.8.0'); $this->checkForUpdates(); $this->drupalGet('/admin/modules/automatic-update'); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); $page->pressButton('Update'); $this->checkForMetaRefresh(); $this->assertUpdateStagedTimes(1); // Confirm we are on the confirmation page. - $this->assertUpdateReady(); + $this->assertUpdateReady('9.8.1'); $assert_session->buttonExists('Continue'); - // Return to the start page. + // If we try to return to the start page, we should be redirected back to + // the confirmation page. $this->drupalGet('/admin/modules/automatic-update'); - $assert_session->pageTextContainsOnce('Cannot begin an update because another Composer operation is currently in progress.'); - $assert_session->buttonNotExists('Update'); + $this->assertUpdateReady('9.8.1'); // Delete the existing update. - $page->pressButton('Delete existing update'); - $assert_session->pageTextContains('Staged update deleted'); - $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.'); - + $page->pressButton('Cancel update'); + $assert_session->addressEquals('/admin/reports/updates/automatic-update'); + $assert_session->pageTextContains($cancelled_message); + $assert_session->pageTextNotContains($conflict_message); // Ensure we can start another update after deleting the existing one. $page->pressButton('Update'); $this->checkForMetaRefresh(); // Confirm we are on the confirmation page. - $this->assertUpdateReady(); + $this->assertUpdateReady('9.8.1'); $this->assertUpdateStagedTimes(2); $assert_session->buttonExists('Continue'); - // Cancel the update, then ensure that we are bounced back to the start - // page, and that it will allow us to begin the update anew. + + // 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/automatic-update'); + $assert_session->pageTextContains($conflict_message); + $assert_session->buttonNotExists('Update'); + // 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); + $page->pressButton('Update'); + $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'); - $assert_session->addressEquals('/admin/reports/updates/automatic-update'); - $assert_session->pageTextContains('The update was successfully cancelled.'); - $assert_session->buttonExists('Update'); + // The exception should have been caught and displayed in the messages area. + $assert_session->statusCodeEquals(200); + $destroy_error = 'Cannot destroy the staging area 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. + $this->drupalLogin($this->rootUser); + $this->drupalGet('/admin/reports/updates/automatic-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. + $this->createTestValidationResults(); + $results = $this->testResults['checker_1']['1 error']; + TestSubscriber1::setTestResult($results, PreApplyEvent::class); + $page->pressButton('Update'); + $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); + } + + /** + * Data provider for testStagedDatabaseUpdates(). + * + * @return bool[][] + * The test cases. + */ + public function providerStagedDatabaseUpdates() { + 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(): void { + public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void { $this->setCoreVersion('9.8.0'); $this->checkForUpdates(); + $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. $this->createTestValidationResults(); @@ -298,22 +382,18 @@ public function testStagedDatabaseUpdates(): void { $page = $this->getSession()->getPage(); $this->drupalGet('/admin/modules/automatic-update'); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); // The warning should be visible. $assert_session = $this->assertSession(); $assert_session->pageTextContains(reset($messages)); $page->pressButton('Update'); $this->checkForMetaRefresh(); $this->assertUpdateStagedTimes(1); - $this->assertUpdateReady(); - // Simulate a staged database update in the System 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.staged_database_updates', [ - 'system' => [ - 'name' => 'System', - ], - ]); + $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 not be repeated, but we // should see a warning about pending database updates, and once the staged // changes have been applied, we should be redirected to update.php, where @@ -322,12 +402,55 @@ public function testStagedDatabaseUpdates(): void { $possible_update_message = 'Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.'; $assert_session->pageTextContains($possible_update_message); $assert_session->pageTextContains('System'); + $assert_session->checkboxChecked('maintenance_mode'); $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->addressEquals('/update.php'); $assert_session->pageTextNotContains(reset($messages)); $assert_session->pageTextNotContains($possible_update_message); $assert_session->pageTextContainsOnce('Please apply database updates to complete the update process.'); + $this->assertTrue($state->get('system.maintenance_mode')); + $page->clickLink('Continue'); + // @see auto_updates_update_9001() + $assert_session->pageTextContains('Dynamic auto_updates_update_9001'); + $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); + } + + /** + * Data provider for testSuccessfulUpdate(). + * + * @return string[][] + * Test case parameters. + */ + public function providerSuccessfulUpdate(): array { + return [ + 'Modules page, maintenance mode on' => [ + '/admin/modules/automatic-update', + TRUE, + ], + 'Modules page, maintenance mode off' => [ + '/admin/modules/automatic-update', + FALSE, + ], + 'Reports page, maintenance mode on' => [ + '/admin/reports/updates/automatic-update', + TRUE, + ], + 'Reports page, maintenance mode off' => [ + '/admin/reports/updates/automatic-update', + FALSE, + ], + ]; } /** @@ -335,24 +458,96 @@ public function testStagedDatabaseUpdates(): void { * * @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 providerUpdateFormReferringUrl + * @dataProvider providerSuccessfulUpdate */ - public function testSuccessfulUpdate(string $update_form_url): void { + public function testSuccessfulUpdate(string $update_form_url, bool $maintenance_mode_on): void { $this->setCoreVersion('9.8.0'); $this->checkForUpdates(); + $state = $this->container->get('state'); + $state->set('system.maintenance_mode', $maintenance_mode_on); $page = $this->getSession()->getPage(); $this->drupalGet($update_form_url); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); $page->pressButton('Update'); $this->checkForMetaRefresh(); $this->assertUpdateStagedTimes(1); - $this->assertUpdateReady(); + $this->assertUpdateReady('9.8.1'); + // Confirm that the site was put into maintenance mode if needed. + $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on); $page->pressButton('Continue'); $this->checkForMetaRefresh(); $assert_session = $this->assertSession(); $assert_session->addressEquals('/admin/reports/updates'); + // 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->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on); + } + + /** + * Tests what happens when a staged update is deleted without being destroyed. + */ + public function testStagedUpdateDeletedImproperly(): void { + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $page = $this->getSession()->getPage(); + $this->drupalGet('/admin/modules/automatic-update'); + FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1'); + $page->pressButton('Update'); + $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() + $dir = FileSystem::getOsTemporaryDirectory() . '/.package_manager' . $this->config('system.site')->get('uuid'); + $this->assertDirectoryExists($dir); + $this->container->get('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/automatic-update'); + $assert_session->pageTextNotContains($error_message); + $assert_session->pageTextContains('The update was successfully cancelled.'); + $assert_session->buttonExists('Update'); + } + + /** + * Tests that the update stage is destroyed if an error occurs during require. + */ + public function testStageDestroyedOnError(): void { + $session = $this->getSession(); + $assert_session = $this->assertSession(); + $page = $session->getPage(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $this->drupalGet('/admin/modules/automatic-update'); + $error = new \Exception('Some Exception'); + TestSubscriber1::setException($error, PostRequireEvent::class); + $assert_session->pageTextNotContains(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + $assert_session->pageTextContainsOnce('An error has occurred.'); + $page->clickLink('the error page'); + $assert_session->addressEquals('/admin/modules/automatic-update'); + $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.'); + $assert_session->buttonNotExists('Delete existing update'); + $assert_session->pageTextContains('Some Exception'); + $assert_session->buttonExists('Update'); } } diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php index 1194677000cc..f6c0efbaaf8c 100644 --- a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php +++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php @@ -55,15 +55,8 @@ protected function setUp(): void { $this->installConfig('update'); // Make the update system think that all of System's post-update functions - // have run. Since kernel tests don't normally install modules and register - // their updates, we need to do this so that all validators are tested from - // a clean, fully up-to-date state. - $updates = $this->container->get('update.post_update_registry') - ->getPendingUpdateFunctions(); - - $this->container->get('keyvalue') - ->get('post_update') - ->set('existing_updates', $updates); + // have run. + $this->registerPostUpdateFunctions(); // By default, pretend we're running Drupal core 9.8.0 and a non-security // update to 9.8.1 is available. @@ -138,8 +131,8 @@ class TestUpdater extends Updater { /** * {@inheritdoc} */ - protected static function getStagingRoot(): string { - return TestStage::getStagingRoot(); + public function getStagingRoot(): string { + return TestStage::$stagingRoot ?: parent::getStagingRoot(); } } @@ -152,8 +145,8 @@ class TestCronUpdater extends CronUpdater { /** * {@inheritdoc} */ - protected static function getStagingRoot(): string { - return TestStage::getStagingRoot(); + public function getStagingRoot(): string { + return TestStage::$stagingRoot ?: parent::getStagingRoot(); } /** diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php index 50b28eb70981..97a3a2939fd4 100644 --- a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php @@ -4,6 +4,7 @@ use Drupal\auto_updates\CronUpdater; use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1; +use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Form\FormState; use Drupal\Core\Logger\RfcLogLevel; use Drupal\package_manager\Event\PostApplyEvent; @@ -63,6 +64,19 @@ protected function setUp(): void { ->addLogger($this->logger); } + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // 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'); + } + /** * Data provider for ::testUpdaterCalled(). * @@ -96,7 +110,7 @@ public function providerUpdaterCalled(): array { 'enabled, normal release' => [ CronUpdater::ALL, "$fixture_dir/drupal.9.8.2.xml", - TRUE, + FALSE, ], 'enabled, security release' => [ CronUpdater::ALL, diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php index 547471dbe5a3..a915c6b65d4c 100644 --- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php @@ -50,6 +50,7 @@ public function providerValidatorInvoked(): array { 'Pending updates validator' => ['package_manager.validator.pending_updates'], 'File system validator' => ['package_manager.validator.file_system'], 'Composer settings validator' => ['package_manager.validator.composer_settings'], + 'Multisite validator' => ['package_manager.validator.multisite'], ]; } diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php index 5e14c677ff91..8ff50db1a451 100644 --- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php @@ -30,6 +30,7 @@ class ReadinessValidationManagerTest extends AutoUpdatesKernelTestBase { */ protected function setUp(): void { parent::setUp(); + $this->setCoreVersion('9.8.2'); $this->installEntitySchema('user'); $this->installSchema('user', ['users_data']); $this->createTestValidationResults(); @@ -222,6 +223,7 @@ public function testStoredResultsDeletedPostApply(): void { ->install(['auto_updates']); // Ensure there's a simulated core release to update to. + $this->setCoreVersion('9.8.1'); $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.2.xml'); // The readiness checker should raise a warning, so that the update is not @@ -246,7 +248,7 @@ public function testStoredResultsDeletedPostApply(): void { /** @var \Drupal\auto_updates\Updater $updater */ $updater = $this->container->get('auto_updates.updater'); - $updater->begin(['drupal' => '9.8.1']); + $updater->begin(['drupal' => '9.8.2']); $updater->stage(); $updater->apply(); $updater->destroy(); diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php index 410e2b8d2ce6..0118612e65c4 100644 --- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php @@ -30,6 +30,10 @@ class StagedDatabaseUpdateValidatorTest extends AutoUpdatesKernelTestBase { * {@inheritdoc} */ protected function setUp(): void { + // In this test, we want to disable the lock file validator because, even + // though both the active and stage directories will have a valid lock file, + // this validator will complain because they don't differ at all. + $this->disableValidators[] = 'package_manager.validator.lock_file'; parent::setUp(); TestStage::$stagingRoot = $this->vfsRoot->url(); diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php index d9188a6fd888..7162e24aad70 100644 --- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php @@ -3,11 +3,8 @@ namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; use Drupal\auto_updates\CronUpdater; -use Drupal\Core\Logger\RfcLogLevel; -use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\ValidationResult; use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; -use Drupal\Tests\auto_updates\Kernel\TestCronUpdater; use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; use Psr\Log\Test\TestLogger; @@ -167,11 +164,6 @@ public function testMinorUpdates(bool $allow_minor_updates, string $cron_setting // the update shouldn't have been started. elseif ($expected_results) { $this->assertUpdateStagedTimes(0); - - // An exception exactly like this one should have been thrown by - // CronUpdater::dispatch(), and subsequently caught, formatted as HTML, - // and logged. - $this->assertErrorsWereLogged($expected_results); } // If cron updates are enabled and no validation errors were expected, the // update should have started and nothing should have been logged. @@ -197,8 +189,6 @@ public function providerCronUpdateTwoPatchReleasesAhead(): array { CronUpdater::DISABLED, [], ], - // The latest release is two patch releases ahead, so the update should be - // blocked even though the cron configuration allows it. 'security only' => [ CronUpdater::SECURITY, [$update_disallowed], @@ -230,15 +220,6 @@ public function testCronUpdateTwoPatchReleasesAhead(string $cron_setting, array $this->assertCheckerResultsFromManager($expected_results, TRUE); $this->container->get('cron')->run(); $this->assertUpdateStagedTimes(0); - - // If cron updates are enabled for all patch releases, the error should have - // been raised and logged. - if ($cron_setting === CronUpdater::ALL) { - $this->assertErrorsWereLogged($expected_results); - } - else { - $this->assertArrayNotHasKey(RfcLogLevel::ERROR, $this->logger->recordsByLevel); - } } /** @@ -280,7 +261,13 @@ public function testCronUpdateOnePatchReleaseAhead(string $cron_setting, bool $w $this->config('auto_updates.settings') ->set('cron', $cron_setting) ->save(); - $this->assertCheckerResultsFromManager([], TRUE); + if ($cron_setting === CronUpdater::SECURITY) { + $expected_result = ValidationResult::createError(['Drupal cannot be automatically updated during cron from its current version, 9.8.1, to the recommended version, 9.8.2, because 9.8.2 is not a security release.']); + $this->assertCheckerResultsFromManager([$expected_result], TRUE); + } + else { + $this->assertCheckerResultsFromManager([], TRUE); + } $this->container->get('cron')->run(); $this->assertUpdateStagedTimes((int) $will_update); } @@ -298,9 +285,6 @@ public function providerInvalidCronUpdate(): array { $dev_current_version = ValidationResult::createError([ 'Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.2, because automatic updates from a dev version to any other version are not supported.', ]); - $newer_current_version = ValidationResult::createError([ - 'Update version 9.8.2 is lower than 9.8.3, downgrading is not supported.', - ]); $different_major_version = ValidationResult::createError([ 'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.2, because automatic updates from one major version to another are not supported.', ]); @@ -313,81 +297,46 @@ public function providerInvalidCronUpdate(): array { // the validation will be run with the regular updater, not the cron // updater. [], - [], ], 'unstable current version, security updates allowed' => [ CronUpdater::SECURITY, '9.8.0-alpha1', [$unstable_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'unstable current version, all updates allowed' => [ CronUpdater::ALL, '9.8.0-alpha1', [$unstable_current_version], - [$unstable_current_version], ], 'dev current version, cron disabled' => [ CronUpdater::DISABLED, '9.8.0-dev', [$dev_current_version], - [], ], 'dev current version, security updates allowed' => [ CronUpdater::SECURITY, '9.8.0-dev', [$dev_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'dev current version, all updates allowed' => [ CronUpdater::ALL, '9.8.0-dev', [$dev_current_version], - [$dev_current_version], - ], - 'newer current version, cron disabled' => [ - CronUpdater::DISABLED, - '9.8.3', - [$newer_current_version], - [], - ], - 'newer current version, security updates allowed' => [ - CronUpdater::SECURITY, - '9.8.3', - [$newer_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], - ], - 'newer current version, all updates allowed' => [ - CronUpdater::ALL, - '9.8.3', - [$newer_current_version], - [$newer_current_version], ], 'different current major, cron disabled' => [ CronUpdater::DISABLED, '8.9.1', [$different_major_version], - [], ], 'different current major, security updates allowed' => [ CronUpdater::SECURITY, '8.9.1', [$different_major_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'different current major, all updates allowed' => [ CronUpdater::ALL, '8.9.1', [$different_major_version], - [$different_major_version], ], ]; } @@ -402,12 +351,10 @@ public function providerInvalidCronUpdate(): array { * @param \Drupal\package_manager\ValidationResult[] $expected_results * The validation results, if any, that should be flagged during readiness * checks. - * @param \Drupal\package_manager\ValidationResult[] $logged_results - * The validation results, if any, that should be logged when cron is run. * * @dataProvider providerInvalidCronUpdate */ - public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results, array $logged_results): void { + public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results): void { $this->setCoreVersion($current_core_version); $this->config('auto_updates.settings') ->set('cron', $cron_setting) @@ -423,23 +370,6 @@ public function testInvalidCronUpdate(string $cron_setting, string $current_core // created (in which case, we expect the errors to be logged). $this->container->get('cron')->run(); $this->assertUpdateStagedTimes(0); - if ($logged_results) { - $this->assertErrorsWereLogged($logged_results); - } - } - - /** - * Asserts that validation errors were logged during a cron update. - * - * @param \Drupal\package_manager\ValidationResult[] $results - * The validation errors should have been logged. - */ - private function assertErrorsWereLogged(array $results): void { - $exception = new StageValidationException($results, 'Unable to complete the update because of errors.'); - // The exception will be formatted in a specific, predictable way. - // @see \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest::testErrors() - $message = TestCronUpdater::formatValidationException($exception); - $this->assertTrue($this->logger->hasRecord($message, RfcLogLevel::ERROR)); } } 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..bb76a2cdf330 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php @@ -0,0 +1,171 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\update\ProjectRelease; + +/** + * @coversDefaultClass \Drupal\auto_updates\ReleaseChooser + * + * @group auto_updates + */ +class ReleaseChooserTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2-older-sec-release.xml'); + + } + + /** + * Data provider for testReleases(). + * + * @return array[] + * The test cases. + */ + public function providerReleases(): array { + return [ + 'installed 9.8.0, no minor support' => [ + 'chooser' => 'auto_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.8.0, minor support' => [ + 'chooser' => 'auto_updates.release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.7.0, no minor support' => [ + 'chooser' => 'auto_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'installed 9.7.0, minor support' => [ + 'chooser' => 'auto_updates.release_chooser', + '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' => [ + 'chooser' => 'auto_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'installed 9.7.2, minor support' => [ + 'chooser' => 'auto_updates.release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => '9.8.2', + ], + 'cron, installed 9.8.0, no minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.8.0, minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, no minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, no minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, minor support' => [ + 'chooser' => 'auto_updates.cron_release_chooser', + '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 $chooser_service + * The ID of release chooser 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 + */ + public function testReleases(string $chooser_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(); + /** @var \Drupal\auto_updates\ReleaseChooser $chooser */ + $chooser = $this->container->get($chooser_service); + $chooser->refresh(); + $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor()); + $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor()); + } + + /** + * 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) { + if (is_null($version)) { + $this->assertNull($release); + } + else { + $this->assertNotEmpty($release); + $this->assertSame($version, $release->getVersion()); + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php deleted file mode 100644 index b22029bfbce2..000000000000 --- a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php - -namespace Drupal\Tests\auto_updates\Kernel; - -use Drupal\auto_updates\UpdateRecommender; - -/** - * @covers \Drupal\auto_updates\UpdateRecommender - * - * @group auto_updates - */ -class UpdateRecommenderTest extends AutoUpdatesKernelTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = [ - 'auto_updates', - 'package_manager', - ]; - - /** - * Tests fetching the recommended release when an update is available. - */ - public function testUpdateAvailable(): void { - $recommender = new UpdateRecommender(); - $recommended_release = $recommender->getRecommendedRelease(TRUE); - $this->assertNotEmpty($recommended_release); - $this->assertSame('9.8.2', $recommended_release->getVersion()); - // Getting the recommended release again should not trigger another request. - $this->assertNotEmpty($recommender->getRecommendedRelease()); - } - - /** - * Tests fetching the recommended release when there is no update available. - */ - public function testNoUpdateAvailable(): void { - $this->setCoreVersion('9.8.2'); - - $recommender = new UpdateRecommender(); - $recommended_release = $recommender->getRecommendedRelease(TRUE); - $this->assertNull($recommended_release); - // Getting the recommended release again should not trigger another request. - $this->assertNull($recommender->getRecommendedRelease()); - } - -} diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php index 2453e8e38e44..d4fe45537c65 100644 --- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php @@ -33,7 +33,7 @@ protected function setUp(): void { /** * Tests that correct versions are staged after calling ::begin(). */ - public function testCorrectVersionsStaged() { + public function testCorrectVersionsStaged(): void { $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); // Create a user who will own the stage even after the container is rebuilt. diff --git a/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php b/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php new file mode 100644 index 000000000000..0c1c4e82ef23 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php @@ -0,0 +1,194 @@ +<?php + +namespace Drupal\Tests\auto_updates\Unit; + +use Drupal\auto_updates\ProjectInfo; +use Drupal\update\ProjectRelease; +use Drupal\Tests\UnitTestCase; +use Drupal\update\UpdateManagerInterface; + +/** + * @coversDefaultClass \Drupal\auto_updates\ProjectInfo + * + * @group auto_updates + */ +class ProjectInfoTest extends UnitTestCase { + + /** + * Creates release data for testing. + * + * @return string[][] + * The release information. + */ + private static function createTestReleases(): array { + $versions = ['8.2.5', '8.2.4', '8.2.3', '8.2.3-alpha']; + foreach ($versions as $version) { + $release_arrays[$version] = [ + 'status' => 'published', + 'version' => $version, + 'release_link' => "https://example.drupal.org/project/drupal/releases/$version", + ]; + } + return $release_arrays; + } + + /** + * Data provider for testGetInstallableReleases(). + * + * @return array[][] + * The test cases. + */ + public function providerGetInstallableReleases(): array { + $release_arrays = static::createTestReleases(); + foreach ($release_arrays as $version => $release_array) { + $release_objects[$version] = ProjectRelease::createFromArray($release_array); + } + return [ + 'current' => [ + [ + 'status' => UpdateManagerInterface::CURRENT, + 'existing_version' => '1.2.3', + ], + [], + ], + '1 release' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.4', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + ], + ], + '1 releases, also security' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.4', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + $release_arrays['8.2.5'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + ], + ], + '1 release, other security' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.2', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + // Set out of order security releases to ensure results are sorted. + $release_arrays['8.2.3-alpha'], + $release_arrays['8.2.3'], + $release_arrays['8.2.4'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + '8.2.4' => $release_objects['8.2.4'], + '8.2.3' => $release_objects['8.2.3'], + '8.2.3-alpha' => $release_objects['8.2.3-alpha'], + ], + ], + '1 releases, other security lower than current version' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.3', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + // Set out of order security releases to ensure results are sorted. + $release_arrays['8.2.3-alpha'], + $release_arrays['8.2.3'], + $release_arrays['8.2.4'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + '8.2.4' => $release_objects['8.2.4'], + ], + ], + ]; + } + + /** + * @covers ::getInstallableReleases + * + * @param array $project_data + * The project data to return from ::getProjectInfo(). + * @param \Drupal\update\ProjectRelease[] $expected_releases + * The expected releases. + * + * @dataProvider providerGetInstallableReleases + */ + public function testGetInstallableReleases(array $project_data, array $expected_releases): void { + $project_info = $this->getMockedProjectInfo($project_data); + $this->assertEqualsCanonicalizing($expected_releases, $project_info->getInstallableReleases()); + } + + /** + * @covers ::getInstallableReleases + */ + public function testInvalidProjectData(): void { + $release_arrays = static::createTestReleases(); + $project_data = [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '1.2.3', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + $release_arrays['8.2.4'], + $release_arrays['8.2.3'], + $release_arrays['8.2.3-alpha'], + ], + ]; + $project_info = $this->getMockedProjectInfo($project_data); + $this->expectException('LogicException'); + $this->expectExceptionMessage('Drupal core is out of date, but the recommended version could not be determined.'); + $project_info->getInstallableReleases(); + } + + /** + * @covers ::getInstalledVersion + */ + public function testGetInstalledVersion(): void { + $project_info = $this->getMockedProjectInfo(['existing_version' => '1.2.3']); + $this->assertSame('1.2.3', $project_info->getInstalledVersion()); + } + + /** + * Mocks a ProjectInfo object. + * + * @param array $project_data + * The project info that should be returned by the mock's ::getProjectInfo() + * method. + * + * @return \Drupal\auto_updates\ProjectInfo + * The mocked object. + */ + private function getMockedProjectInfo(array $project_data): ProjectInfo { + $project_info = $this->getMockBuilder(ProjectInfo::class) + ->onlyMethods(['getProjectInfo']) + ->getMock(); + $project_info->expects($this->any()) + ->method('getProjectInfo') + ->willReturn($project_data); + return $project_info; + } + +} diff --git a/core/modules/package_manager/package_manager.module b/core/modules/package_manager/package_manager.module index 8560fc55ab99..c54269edfcc9 100644 --- a/core/modules/package_manager/package_manager.module +++ b/core/modules/package_manager/package_manager.module @@ -6,17 +6,26 @@ */ use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\package_manager\Validator\ComposerExecutableValidator; /** * Implements hook_help(). */ function package_manager_help($route_name, RouteMatchInterface $route_match) { - // @todo Fully document all the modules features in - // https://www.drupal.org/i/3253591. switch ($route_name) { case 'help.page.package_manager': $output = '<h3>' . t('About') . '</h3>'; - $output .= '<p>' . t('Package Manager is an API for installing and updating Drupal core and contributed modules.') . '</p>'; + $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>' . t('Requirements') . '</h3>'; + $output .= '<p>' . t('Package Manager requires Composer @version or later available as an executable, 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' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION]) . '</p>'; + $output .= '<h3>' . 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 from being made to the live site:") . '</p>'; + $output .= '<ul>'; + $output .= '<li>' . t('Package Manager 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('The temporary copy of the site is associated with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>'; + $output .= '<li>' . t('Modules cannot be uninstalled while Package Manager is syncing changes into live site.') . '<li>'; + $output .= '</ul>'; $output .= '<p>' . t('For more information, see the <a href=":package-manager-documentation">online documentation for the Package Manager module</a>.', [':package-manager-documentation' => 'https://www.drupal.org/docs/8/core/modules/package-manager']) . '</p>'; return $output; } diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml index 7480aaae798c..75ae35d2ece1 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -120,6 +120,13 @@ services: - '@string_translation' tags: - { name: event_subscriber } + package_manager.validator.multisite: + class: Drupal\package_manager\Validator\MultisiteValidator + arguments: + - '@package_manager.path_locator' + - '@string_translation' + tags: + - { name: event_subscriber } package_manager.excluded_paths_subscriber: class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber arguments: @@ -130,3 +137,11 @@ services: - '@package_manager.path_locator' tags: - { name: event_subscriber } + package_manager.uninstall_validator: + class: Drupal\package_manager\PackageManagerUninstallValidator + tags: + - { name: module_install.uninstall_validator } + parent: container.trait + calls: + - ['setContainer', ['@service_container']] + lazy: true diff --git a/core/modules/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/package_manager/src/Event/PreOperationStageEvent.php index e60ef7b28ceb..226af627bd11 100644 --- a/core/modules/package_manager/src/Event/PreOperationStageEvent.php +++ b/core/modules/package_manager/src/Event/PreOperationStageEvent.php @@ -39,7 +39,7 @@ public function getResults(?int $severity = NULL): array { /** * Adds error information to the event. */ - public function addError(array $messages, ?TranslatableMarkup $summary = NULL) { + public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void { $this->results[] = ValidationResult::createError($messages, $summary); } diff --git a/core/modules/package_manager/src/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/PackageManagerUninstallValidator.php new file mode 100644 index 000000000000..dd9ab876071e --- /dev/null +++ b/core/modules/package_manager/src/PackageManagerUninstallValidator.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\Extension\ModuleUninstallValidatorInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\DependencyInjection\ContainerAwareInterface; +use Symfony\Component\DependencyInjection\ContainerAwareTrait; + +/** + * Prevents any module from being uninstalled if update is in process. + */ +class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface, ContainerAwareInterface { + + use ContainerAwareTrait; + use StringTranslationTrait; + + /** + * {@inheritdoc} + */ + public function validate($module) { + $stage = new Stage( + $this->container->get('config.factory'), + $this->container->get('package_manager.path_locator'), + $this->container->get('package_manager.beginner'), + $this->container->get('package_manager.stager'), + $this->container->get('package_manager.committer'), + $this->container->get('file_system'), + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared'), + $this->container->get('datetime.time') + ); + 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/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/Stage.php b/core/modules/package_manager/src/Stage.php index 4c7121075303..810806fe743a 100644 --- a/core/modules/package_manager/src/Stage.php +++ b/core/modules/package_manager/src/Stage.php @@ -2,8 +2,10 @@ namespace Drupal\package_manager; +use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\FileSystem\FileSystem; use Drupal\Component\Utility\Crypt; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\TempStore\SharedTempStoreFactory; @@ -38,6 +40,16 @@ * operations on the staging area, 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 staging area, 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 staging + * directory has a randomly generated name. For additional cleanliness, all + * staging directories created by a specific site live in a single directory, + * called the "staging root" 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. */ class Stage { @@ -55,6 +67,23 @@ class Stage { */ protected const TEMPSTORE_METADATA_KEY = 'metadata'; + /** + * 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 config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + /** * The path locator service. * @@ -104,6 +133,13 @@ class Stage { */ protected $tempStore; + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + /** * The lock info for the stage. * @@ -116,6 +152,8 @@ class Stage { /** * Constructs a new Stage object. * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. * @param \Drupal\package_manager\PathLocator $path_locator * The path locator service. * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner @@ -130,14 +168,18 @@ class Stage { * The event dispatcher service. * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore * The shared tempstore factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. */ - public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) { + public function __construct(ConfigFactoryInterface $config_factory, PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore, TimeInterface $time) { + $this->configFactory = $config_factory; $this->pathLocator = $path_locator; $this->beginner = $beginner; $this->stager = $stager; $this->committer = $committer; $this->fileSystem = $file_system; $this->eventDispatcher = $event_dispatcher; + $this->time = $time; $this->tempStore = $shared_tempstore->get('package_manager_stage'); } @@ -224,7 +266,9 @@ public function create(): string { $stage_dir = $this->getStageDirectory(); $event = new PreCreateEvent($this); - $this->dispatch($event); + // If an error occurs and we won't be able to create the stage, mark it as + // available. + $this->dispatch($event, [$this, 'markAsAvailable']); $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths()); $this->dispatch(new PostCreateEvent($this)); @@ -276,11 +320,29 @@ public function apply(): void { $active_dir = $this->pathLocator->getProjectRoot(); $stage_dir = $this->getStageDirectory(); + // 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. + $release_apply = function (): void { + $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY); + }; + $event = new PreApplyEvent($this); - $this->dispatch($event); + $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime()); + $this->dispatch($event, $release_apply); $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths()); - $this->dispatch(new PostApplyEvent($this)); + + // 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'); + + $this->dispatch(new PostApplyEvent($this), $release_apply); + $release_apply(); } /** @@ -290,29 +352,29 @@ public function apply(): void { * (optional) If TRUE, the staging area will be destroyed even if it is not * owned by the current user or session. Defaults to FALSE. * - * @todo Do not allow the stage to be destroyed while it's being applied to - * the active directory in https://www.drupal.org/i/3248909. + * @throws \Drupal\package_manager\Exception\StageException + * If the staged changes are being applied to the active directory. */ public function destroy(bool $force = FALSE): void { if (!$force) { $this->checkOwnership(); } + if ($this->isApplying()) { + throw new StageException('Cannot destroy the staging area while it is being applied to the active directory.'); + } $this->dispatch(new PreDestroyEvent($this)); - // Delete all directories in parent staging directory. - $parent_stage_dir = static::getStagingRoot(); - if (is_dir($parent_stage_dir)) { - try { - $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void { - $this->fileSystem->chmod($path, 0777); - }); - } - catch (FileException $e) { - // Deliberately swallow the exception so that the stage will be marked - // as available and the post-destroy event will be fired, even if the - // staging area can't actually be deleted. The file system service logs - // the exception, so we don't need to do anything else here. - } + // Delete the staging root and everything in it. + try { + $this->fileSystem->deleteRecursive($this->getStagingRoot(), function (string $path): void { + $this->fileSystem->chmod($path, 0777); + }); + } + catch (FileException $e) { + // Deliberately swallow the exception so that the stage will be marked + // as available and the post-destroy event will be fired, even if the + // staging area can't actually be deleted. The file system service logs + // the exception, so we don't need to do anything else here. } $this->markAsAvailable(); $this->dispatch(new PostDestroyEvent($this)); @@ -332,13 +394,16 @@ protected function markAsAvailable(): void { * * @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\StageValidationException * If the event collects any validation errors. * @throws \Drupal\package_manager\Exception\StageException * If any other sort of error occurs. */ - protected function dispatch(StageEvent $event): void { + protected function dispatch(StageEvent $event, callable $on_error = NULL): void { try { $this->eventDispatcher->dispatch($event); @@ -354,10 +419,8 @@ protected function dispatch(StageEvent $event): void { } if (isset($error)) { - // If we won't be able to create the staging area, mark it as available. - // @see ::create() - if ($event instanceof PreCreateEvent) { - $this->markAsAvailable(); + if ($on_error) { + $on_error(); } throw $error; } @@ -459,14 +522,12 @@ final protected function checkOwnership(): void { * * @throws \LogicException * If this method is called before the stage has been created or claimed. - * - * @todo Make this method public in https://www.drupal.org/i/3251972. */ public function getStageDirectory(): string { if (!$this->lock) { throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.'); } - return static::getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0]; + return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0]; } /** @@ -476,8 +537,26 @@ public function getStageDirectory(): string { * The absolute path of the directory containing the staging areas managed * by this class. */ - protected static function getStagingRoot(): string { - return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager'; + protected function getStagingRoot(): string { + $site_id = $this->configFactory->get('system.site')->get('uuid'); + return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id; + } + + /** + * 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; } } diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php index 7f29b5e1cc67..a1676f3e74b2 100644 --- a/core/modules/package_manager/src/Validator/LockFileValidator.php +++ b/core/modules/package_manager/src/Validator/LockFileValidator.php @@ -57,14 +57,17 @@ public function __construct(StateInterface $state, PathLocator $path_locator, Tr } /** - * Returns the current hash of the active directory's lock file. + * Returns the current hash of the given directory's lock file. + * + * @param string $directory + * Path of a directory containing a composer.lock file. * * @return string|false - * The hash of the active directory's lock file, or FALSE if the lock file + * The hash of the given directory's lock file, or FALSE if the lock file * does not exist. */ - protected function getHash() { - $file = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock'; + protected function getLockFileHash(string $directory) { + $file = $directory . DIRECTORY_SEPARATOR . 'composer.lock'; // We want to directly hash the lock file itself, rather than look at its // content-hash value, which is actually a hash of the relevant parts of // composer.json. We're trying to verify that the actual installed packages @@ -81,7 +84,7 @@ protected function getHash() { * Stores the current lock file hash. */ public function storeHash(PreCreateEvent $event): void { - $hash = $this->getHash(); + $hash = $this->getLockFileHash($this->pathLocator->getProjectRoot()); if ($hash) { $this->state->set(static::STATE_KEY, $hash); } @@ -97,8 +100,8 @@ public function storeHash(PreCreateEvent $event): void { */ public function validateStagePreOperation(PreOperationStageEvent $event): void { // Ensure we can get a current hash of the lock file. - $hash = $this->getHash(); - if (empty($hash)) { + $active_hash = $this->getLockFileHash($this->pathLocator->getProjectRoot()); + if (empty($active_hash)) { $error = $this->t('Could not hash the active lock file.'); } @@ -109,10 +112,19 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void { } // If we have both hashes, ensure they match. - if ($hash && $stored_hash && !hash_equals($stored_hash, $hash)) { + if ($active_hash && $stored_hash && !hash_equals($stored_hash, $active_hash)) { $error = $this->t('Stored lock file hash does not match the active lock file.'); } + // Don't allow staged changes to be applied if the staged lock file has no + // apparent changes. + if (empty($error) && $event instanceof PreApplyEvent) { + $stage_hash = $this->getLockFileHash($event->getStage()->getStageDirectory()); + if ($stage_hash && hash_equals($active_hash, $stage_hash)) { + $error = $this->t('There are no pending Composer operations.'); + } + } + // @todo Let the validation result carry all the relevant messages in // https://www.drupal.org/i/3247479. if (isset($error)) { 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..8a8ebb4c8be3 --- /dev/null +++ b/core/modules/package_manager/src/Validator/MultisiteValidator.php @@ -0,0 +1,74 @@ +<?php + +namespace Drupal\package_manager\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\PathLocator; + +/** + * Checks that the current site is not part of a multisite. + */ +class MultisiteValidator implements PreOperationStageValidatorInterface { + + use StringTranslationTrait; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a new MultisiteValidator. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation service. + */ + public function __construct(PathLocator $path_locator, TranslationInterface $translation) { + $this->pathLocator = $path_locator; + $this->setStringTranslation($translation); + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + if ($this->isMultisite()) { + $event->addError([ + $this->t('Multisites are 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. + * + * @todo Make this smarter in https://www.drupal.org/node/3267646. + */ + protected function isMultisite(): bool { + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $web_root .= '/'; + } + return file_exists($this->pathLocator->getProjectRoot() . '/' . $web_root . 'sites/sites.php'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + ]; + } + +} diff --git a/core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json new file mode 100644 index 000000000000..35db7d858c4e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/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/alpha/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json new file mode 100644 index 000000000000..f21a204a76df --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/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/updated_module/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json new file mode 100644 index 000000000000..777cd741d249 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/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/updated_module/1.0.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml new file mode 100644 index 000000000000..ebf1452ec9c8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml @@ -0,0 +1,4 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module new file mode 100644 index 000000000000..6f61c457881c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module @@ -0,0 +1,27 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return array + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} + +/** + * A test function to test if global functions are reloaded during an update. + * + * @return string + */ +function _updated_module_global1(): string { + return "pre-update-value"; +} diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml new file mode 100644 index 000000000000..c0236005ef27 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml @@ -0,0 +1,4 @@ +changed permission: + title: 'permission' +deleted permission: + title: 'deleted permission' diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml new file mode 100644 index 000000000000..03a269bb8357 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml @@ -0,0 +1,12 @@ +updated_module.changed: + path: '/updated-module/changed/pre' + defaults: + _controller: 'updated_module_hello' + requirements: + _access: 'TRUE' +updated_module.deleted: + path: '/updated-module/deleted' + defaults: + _controller: 'updated_module_hello' + requirements: + _access: 'TRUE' diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json new file mode 100644 index 000000000000..6f997dad4cf4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/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/updated_module/1.1.0/src/PostApplySubscriber.php b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php new file mode 100644 index 000000000000..2e7e988a3b77 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php @@ -0,0 +1,52 @@ +<?php + +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() { + return [ + PostApplyEvent::class => 'postApply', + ]; + } + +} diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml new file mode 100644 index 000000000000..ebf1452ec9c8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml @@ -0,0 +1,4 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module new file mode 100644 index 000000000000..34d5dce9bb1f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module @@ -0,0 +1,33 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return array + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} + +/** + * A test function to test if global functions are reloaded during an update. + * + * @return string + */ +function _updated_module_global1(): string { + return "post-update-value"; +} + +/** + * A test function to test if a new global function will be available after an update. + */ +function _updated_module_global2(): void { +} diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml new file mode 100644 index 000000000000..926a17a41238 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml @@ -0,0 +1,4 @@ +changed permission: + title: 'changed permission' +added permission: + title: 'added permission' diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml new file mode 100644 index 000000000000..525acd210ccb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml @@ -0,0 +1,12 @@ +updated_module.changed: + path: '/updated-module/changed/post' + defaults: + _controller: 'updated_module_hello' + requirements: + _access: 'TRUE' +updated_module.added: + path: '/updated-module/added' + defaults: + _controller: 'updated_module_hello' + requirements: + _access: 'TRUE' diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml new file mode 100644 index 000000000000..1179d2fb06f2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/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: + - '@package_manager.path_locator' + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php index feb4dc92f688..edc1075a9aaf 100644 --- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php @@ -27,7 +27,7 @@ public function getInvocationArguments(): array { * @param mixed ...$arguments * The arguments that the main class method was called with. */ - protected function saveInvocationArguments(...$arguments) { + protected function saveInvocationArguments(...$arguments): void { $invocations = $this->getInvocationArguments(); $invocations[] = $arguments; \Drupal::state()->set(static::class, $invocations); 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 index 4b5cf58ca1f5..f400c0f4fcd7 100644 --- 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 @@ -1,6 +1,6 @@ -package_manager_test_api.require: - path: '/package-manager-test-api/require' +package_manager_test_api: + path: '/package-manager-test-api' defaults: - _controller: 'Drupal\package_manager_test_api\ApiController::require' + _controller: 'Drupal\package_manager_test_api\ApiController::run' requirements: _access: 'TRUE' diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml new file mode 100644 index 000000000000..6bf6af37d16d --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml @@ -0,0 +1,10 @@ +services: + package_manager_test_api.system_change_recorder: + class: Drupal\package_manager_test_api\SystemChangeRecorder + arguments: + - '@package_manager.path_locator' + - '@state' + - '@router.no_access_checks' + - '@user.permissions' + tags: + - { name: event_subscriber } 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 index a9f116ff2bc8..45eb27f0f8d1 100644 --- 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 @@ -3,6 +3,7 @@ namespace Drupal\package_manager_test_api; use Drupal\Core\Controller\ControllerBase; +use Drupal\package_manager\PathLocator; use Drupal\package_manager\Stage; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -20,14 +21,24 @@ class ApiController extends ControllerBase { */ private $stage; + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + /** * Constructs an ApiController object. * * @param \Drupal\package_manager\Stage $stage * The stage. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. */ - public function __construct(Stage $stage) { + public function __construct(Stage $stage, PathLocator $path_locator) { $this->stage = $stage; + $this->pathLocator = $path_locator; } /** @@ -35,6 +46,7 @@ public function __construct(Stage $stage) { */ public static function create(ContainerInterface $container) { $stage = new Stage( + $container->get('config.factory'), $container->get('package_manager.path_locator'), $container->get('package_manager.beginner'), $container->get('package_manager.stager'), @@ -42,40 +54,47 @@ public static function create(ContainerInterface $container) { $container->get('file_system'), $container->get('event_dispatcher'), $container->get('tempstore.shared'), + $container->get('datetime.time') + ); + return new static( + $stage, + $container->get('package_manager.path_locator') ); - return new static($stage); } /** - * Creates a staging area and requires packages into it. + * Runs a complete stage life cycle. + * + * Creates a staging area, requires packages into it, applies changes to the + * active directory, and destroys the stage. * * @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. There may also be a 'files_to_return' key, which - * contains an array of file paths, relative to the stage directory, whose + * contains an array of file paths, relative to the project root, whose * contents should be returned in the response. * * @return \Symfony\Component\HttpFoundation\JsonResponse * A JSON response containing an associative array of the contents of the - * staged files listed in the 'files_to_return' request key. The array will - * be keyed by path, relative to the stage directory. + * files listed in the 'files_to_return' request key. The array will be + * keyed by path, relative to the project root. */ - public function require(Request $request): JsonResponse { + public function run(Request $request): JsonResponse { $this->stage->create(); $this->stage->require( $request->get('runtime', []), $request->get('dev', []) ); + $this->stage->apply(); + $this->stage->destroy(); - $stage_dir = $this->stage->getStageDirectory(); - $staged_file_contents = []; + $dir = $this->pathLocator->getProjectRoot(); + $file_contents = []; foreach ($request->get('files_to_return', []) as $path) { - $staged_file_contents[$path] = file_get_contents($stage_dir . '/' . $path); + $file_contents[$path] = file_get_contents($dir . '/' . $path); } - $this->stage->destroy(); - - return new JsonResponse($staged_file_contents); + return new JsonResponse($file_contents); } } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php new file mode 100644 index 000000000000..4738a3157c36 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php @@ -0,0 +1,129 @@ +<?php + +namespace Drupal\package_manager_test_api; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\PathLocator; +use Drupal\user\PermissionHandlerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Routing\RouterInterface; + +/** + * Defines a service for checking system changes during an update. + */ +class SystemChangeRecorder implements EventSubscriberInterface { + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * The router service. + * + * @var \Symfony\Component\Routing\RouterInterface + */ + private $router; + + /** + * The permission handler service. + * + * @var \Drupal\user\PermissionHandlerInterface + */ + private $permissionHandler; + + /** + * Constructs a SystemChangeRecorder object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Symfony\Component\Routing\RouterInterface $router + * The router service. + * @param \Drupal\user\PermissionHandlerInterface $permissionHandler + * The permission handler service. + */ + public function __construct(PathLocator $path_locator, StateInterface $state, RouterInterface $router, PermissionHandlerInterface $permissionHandler) { + $this->pathLocator = $path_locator; + $this->state = $state; + $this->router = $router; + $this->permissionHandler = $permissionHandler; + } + + /** + * Records aspects of system state at various points during an update. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The stage event. + */ + public function recordSystemState(StageEvent $event): void { + $results = []; + + // Call a function in a loaded file to ensure it doesn't get reloaded after + // changes are applied. + $results['return value of existing global function'] = _updated_module_global1(); + + // Check if a new global function exists after changes are applied. + $results['new global function exists'] = function_exists('_updated_module_global2') ? "exists" : "not exists"; + + $route_collection = $this->router->getRouteCollection(); + // Check if changes to an existing route are picked up. + $results['path of changed route'] = $route_collection->get('updated_module.changed') + ->getPath(); + // Check if a route removed from the updated module is no longer available. + $results['deleted route exists'] = $route_collection->get('updated_module.deleted') ? 'exists' : 'not exists'; + // Check if a route added in the updated module is available. + $results['new route exists'] = $route_collection->get('updated_module.added') ? 'exists' : 'not exists'; + + $permissions = $this->permissionHandler->getPermissions(); + // Check if changes to an existing permission are picked up. + $results['title of changed permission'] = $permissions['changed permission']['title']; + // Check if a permission removed from the updated module is not available. + $results['deleted permission exists'] = array_key_exists('deleted permission', $permissions) ? 'exists' : 'not exists'; + // Check if a permission added in the updated module is available. + $results['new permission exists'] = array_key_exists('added permission', $permissions) ? 'exists' : 'not exists'; + $phase = $event instanceof PreApplyEvent ? 'pre' : 'post'; + $this->state->set("system_changes:$phase", $results); + } + + /** + * Writes the results of ::recordSystemState() to file. + * + * Build tests do not have access to the Drupal API, so write the results to + * a file so the build test can check them. + */ + public function writeResultsToFile(): void { + $results = [ + 'pre' => $this->state->get('system_changes:pre'), + 'post' => $this->state->get('system_changes:post'), + ]; + $dir = $this->pathLocator->getProjectRoot(); + file_put_contents("$dir/system_changes.json", json_encode($results)); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreApplyEvent::class => 'recordSystemState', + PostApplyEvent::class => 'recordSystemState', + PostDestroyEvent::class => 'writeResultsToFile', + ]; + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml new file mode 100644 index 000000000000..cf1308922d4f --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test Fixture' +description: 'Provides a mechanism for functional tests to stage fixture files.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml new file mode 100644 index 000000000000..aa44e5741e2b --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml @@ -0,0 +1,8 @@ +services: + package_manager_test_fixture.stager: + class: Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager + arguments: + - '@state' + - '@package_manager.symfony_file_system' + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php b/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php new file mode 100644 index 000000000000..a83d92799805 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php @@ -0,0 +1,88 @@ +<?php + +namespace Drupal\package_manager_test_fixture\EventSubscriber; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostRequireEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Defines an event subscriber which copies certain files into the staging area. + * + * This is most useful in conjunction with package_manager_bypass, which quietly + * turns all Composer Stager operations into no-ops. In such cases, no staging + * area will be physically created, but if a test needs to simulate certain + * conditions in a staging area without actually staging the active code base, + * this event subscriber is the way to do it. + */ +class FixtureStager implements EventSubscriberInterface { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The Symfony file system service. + * + * @var \Symfony\Component\Filesystem\Filesystem + */ + protected $fileSystem; + + /** + * Constructs a FixtureStager. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Symfony\Component\Filesystem\Filesystem $file_system + * The Symfony file system service. + */ + public function __construct(StateInterface $state, Filesystem $file_system) { + $this->state = $state; + $this->fileSystem = $file_system; + } + + /** + * Copies files from a fixture into the staging area. + * + * Tests which use this functionality are responsible for cleaning up the + * staging area. + * + * @param \Drupal\package_manager\Event\PostRequireEvent $event + * The event object. + * + * @see \Drupal\Tests\auto_updates\Functional\AutoUpdatesFunctionalTestBase::tearDown() + */ + public function copyFilesFromFixture(PostRequireEvent $event): void { + $fixturePath = $this->state->get(static::class); + if ($fixturePath && is_dir($fixturePath)) { + $this->fileSystem->mirror($fixturePath, $event->getStage()->getStageDirectory(), NULL, [ + 'override' => TRUE, + 'delete' => TRUE, + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PostRequireEvent::class => 'copyFilesFromFixture', + ]; + } + + /** + * Sets the path of the fixture to copy into the staging area. + * + * @param string $path + * The path of the fixture to copy into the staging area. + */ + public static function setFixturePath(string $path): void { + \Drupal::state()->set(static::class, $path); + } + +} 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 index e58267f1bb0d..f88eb4f9a4de 100644 --- 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 @@ -44,6 +44,19 @@ 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(static::STATE_KEY . ".$event", 'exit'); + } + /** * Sets validation results for a specific event. * @@ -99,9 +112,15 @@ public static function setException(?\Throwable $error, string $event): void { public function handleEvent(StageEvent $event): void { $results = $this->state->get(static::STATE_KEY . '.' . 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(); + } /** @var \Drupal\package_manager\ValidationResult $result */ foreach ($results as $result) { if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) { 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..75c03353b95c --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php @@ -0,0 +1,125 @@ +<?php + +namespace Drupal\Tests\package_manager\Build; + +/** + * Tests updating packages in a staging area. + * + * @group package_manager + */ +class PackageUpdateTest extends TemplateProjectTestBase { + + /** + * Tests updating packages in a staging area. + */ + public function testPackageUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.0.0'); + $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.0.0'); + $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/updated_module --update-with-all-dependencies', 'project'); + + $this->installQuickStart('minimal'); + $this->formLogin($this->adminUsername, $this->adminPassword); + // The updated_module provides actual Drupal-facing functionality that we're + // testing as well, so we need to install it. + $this->installModules(['package_manager_test_api', 'updated_module']); + + // Change both modules' upstream version. + $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.1.0'); + $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.1.0'); + + // 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. We ask the API to return the contents + // of both modules' composer.json files, so we can assert that they were + // updated to the versions we expect. + // @see \Drupal\package_manager_test_api\ApiController::run() + $query = http_build_query([ + 'runtime' => [ + 'drupal/updated_module:1.1.0', + ], + 'files_to_return' => [ + 'web/modules/contrib/alpha/composer.json', + 'web/modules/contrib/updated_module/composer.json', + 'bravo.txt', + "system_changes.json", + ], + ]); + $this->visit("/package-manager-test-api?$query"); + $mink = $this->getMink(); + $mink->assertSession()->statusCodeEquals(200); + + $file_contents = $mink->getSession()->getPage()->getContent(); + $file_contents = json_decode($file_contents, TRUE); + + $expected_versions = [ + 'alpha' => '1.0.0', + 'updated_module' => '1.1.0', + ]; + foreach ($expected_versions as $module_name => $expected_version) { + $path = "web/modules/contrib/$module_name/composer.json"; + $module_composer_json = json_decode($file_contents[$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_contents['bravo.txt']); + + $results = json_decode($file_contents['system_changes.json'], TRUE); + $expected_pre_apply_results = [ + 'return value of existing global function' => 'pre-update-value', + 'new global function exists' => 'not exists', + 'path of changed route' => '/updated-module/changed/pre', + 'deleted route exists' => 'exists', + 'new route exists' => 'not exists', + 'title of changed permission' => 'permission', + 'deleted permission exists' => 'exists', + 'new permission exists' => 'not exists', + ]; + $this->assertSame($expected_pre_apply_results, $results['pre']); + + $expected_post_apply_results = [ + // Existing functions will still use the pre-update version. + 'return value of existing global function' => 'pre-update-value', + // New functions that were added in .module files will not be available. + 'new global function exists' => 'not exists', + // Definitions for existing routes should be updated. + 'path of changed route' => '/updated-module/changed/post', + // Routes deleted from the updated module should not be available. + 'deleted route exists' => 'not exists', + // Routes added to the updated module should be available. + 'new route exists' => 'exists', + // Title of the existing permission should be changed. + 'title of changed permission' => 'changed permission', + // Permissions deleted from the updated module should not be available. + 'deleted permission exists' => 'not exists', + // Permissions added to the updated module should be available. + 'new permission exists' => 'exists', + ]; + $this->assertSame($expected_post_apply_results, $results['post']); + } + + /** + * 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. + */ + private function addRepository(string $name, string $path): void { + $this->assertDirectoryExists($path); + + $repository = json_encode([ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ]); + $this->runComposer("composer config repo.$name '$repository'", 'project'); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php deleted file mode 100644 index 8dbf40363380..000000000000 --- a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php +++ /dev/null @@ -1,87 +0,0 @@ -<?php - -namespace Drupal\Tests\package_manager\Build; - -/** - * Tests updating packages in a staging area. - * - * @group package_manager - */ -class StagedUpdateTest extends TemplateProjectTestBase { - - /** - * Tests that a stage only updates packages with changed constraints. - */ - public function testStagedUpdate(): void { - $this->createTestProject('RecommendedProject'); - - $this->createModule('alpha'); - $this->createModule('bravo'); - $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/bravo --update-with-all-dependencies', 'project'); - - $this->installQuickStart('minimal'); - $this->formLogin($this->adminUsername, $this->adminPassword); - $this->installModules(['package_manager_test_api']); - - // Change both modules' upstream version. - $this->runComposer('composer config version 1.1.0', 'alpha'); - $this->runComposer('composer config version 1.1.0', 'bravo'); - - // Use the API endpoint to create a stage and update bravo to 1.1.0. Even - // though both modules are at version 1.1.0, only bravo should be updated. - // We ask the API to return the contents of both modules' staged - // composer.json files, so we can assert that the staged versions are what - // we expect. - // @see \Drupal\package_manager_test_api\ApiController::require() - $query = http_build_query([ - 'runtime' => [ - 'drupal/bravo:1.1.0', - ], - 'files_to_return' => [ - 'web/modules/contrib/alpha/composer.json', - 'web/modules/contrib/bravo/composer.json', - ], - ]); - $this->visit("/package-manager-test-api/require?$query"); - $mink = $this->getMink(); - $mink->assertSession()->statusCodeEquals(200); - - $staged_file_contents = $mink->getSession()->getPage()->getContent(); - $staged_file_contents = json_decode($staged_file_contents, TRUE); - - $expected_versions = [ - 'alpha' => '1.0.0', - 'bravo' => '1.1.0', - ]; - foreach ($expected_versions as $module_name => $expected_version) { - $path = "web/modules/contrib/$module_name/composer.json"; - $staged_composer_json = json_decode($staged_file_contents[$path]); - $this->assertSame($expected_version, $staged_composer_json->version); - } - } - - /** - * Creates an empty module for testing purposes. - * - * @param string $name - * The machine name of the module, which can be added to the test site as - * 'drupal/$name'. - */ - private function createModule(string $name): void { - $dir = $this->getWorkspaceDirectory() . '/' . $name; - mkdir($dir); - $this->assertDirectoryExists($dir); - $this->runComposer("composer init --name drupal/$name --type drupal-module", $name); - $this->runComposer('composer config version 1.0.0', $name); - - $repository = json_encode([ - 'type' => 'path', - 'url' => $dir, - 'options' => [ - 'symlink' => FALSE, - ], - ]); - $this->runComposer("composer config repo.$name '$repository'", 'project'); - } - -} diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php index d406fcac9504..eecde71a0a25 100644 --- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php @@ -14,6 +14,17 @@ */ class ExcludedPathsTest extends PackageManagerKernelTestBase { + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // In this test, we want to disable the lock file validator because, even + // though both the active and stage directories will have a valid lock file, + // this validator will complain because they don't differ at all. + $this->disableValidators[] = 'package_manager.validator.lock_file'; + parent::setUp(); + } + /** * {@inheritdoc} */ diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php index 4126a41d4837..757481d81b81 100644 --- a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\package_manager\Kernel; +use Drupal\package_manager\Event\PostRequireEvent; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Event\PreRequireEvent; @@ -119,6 +120,21 @@ public function testNoStoredHash(string $event_class): void { $this->assertResults([$result], $event_class); } + /** + * Tests validation when the staged and active lock files are identical. + */ + public function testApplyWithNoChange(): void { + $this->addListener(PostRequireEvent::class, function (PostRequireEvent $event) { + $stage_dir = $event->getStage()->getStageDirectory(); + mkdir($stage_dir); + copy("$this->activeDir/composer.lock", "$stage_dir/composer.lock"); + }); + $result = ValidationResult::createError([ + 'There are no pending Composer operations.', + ]); + $this->assertResults([$result], PreApplyEvent::class); + } + /** * Data provider for test methods that validate the staging area. * 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..79c7bbf6b049 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php @@ -0,0 +1,62 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\MultisiteValidator + * + * @group package_manager + */ +class MultisiteValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for ::testMultisite(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerMultisite(): array { + return [ + 'multisite' => [ + TRUE, + [ + ValidationResult::createError([ + 'Multisites are not supported by Package Manager.', + ]), + ], + ], + 'not multisite' => [ + FALSE, + [], + ], + ]; + } + + /** + * Tests that Package Manager flags an error if run in a multisite. + * + * @param bool $is_multisite + * Whether the validator will be in a multisite. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerMultisite + */ + public function testMultisite(bool $is_multisite, array $expected_results = []): void { + $this->createTestProject(); + + // If we should simulate a multisite, ensure there is a sites.php in the + // test project. + // @see \Drupal\package_manager\Validator\MultisiteValidator::isMultisite() + if ($is_multisite) { + $project_root = $this->container->get('package_manager.path_locator') + ->getProjectRoot(); + touch($project_root . '/sites/sites.php'); + } + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index c8926bd92842..abe8c33145ba 100644 --- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -72,13 +72,15 @@ public function register(ContainerBuilder $container) { */ protected function createStage(): TestStage { return new TestStage( + $this->container->get('config.factory'), $this->container->get('package_manager.path_locator'), $this->container->get('package_manager.beginner'), $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), $this->container->get('file_system'), $this->container->get('event_dispatcher'), - $this->container->get('tempstore.shared') + $this->container->get('tempstore.shared'), + $this->container->get('datetime.time') ); } @@ -290,16 +292,16 @@ class TestStage extends Stage { /** * {@inheritdoc} */ - public static function getStagingRoot(): string { + public function getStagingRoot(): string { return static::$stagingRoot ?: parent::getStagingRoot(); } /** * {@inheritdoc} */ - protected function dispatch(StageEvent $event): void { + protected function dispatch(StageEvent $event, callable $on_error = NULL): void { try { - parent::dispatch($event); + parent::dispatch($event, $on_error); } catch (StageException $e) { // Attach the event object to the exception so that test code can verify diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php index b352b58889a4..e154e95d975c 100644 --- a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php +++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php @@ -2,6 +2,7 @@ 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\PostDestroyEvent; @@ -46,6 +47,18 @@ protected function setUp(): void { $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} */ diff --git a/core/modules/package_manager/tests/src/Kernel/StageTest.php b/core/modules/package_manager/tests/src/Kernel/StageTest.php index 0ed5f56049ab..bf68cc8f39bc 100644 --- a/core/modules/package_manager/tests/src/Kernel/StageTest.php +++ b/core/modules/package_manager/tests/src/Kernel/StageTest.php @@ -2,20 +2,76 @@ namespace Drupal\Tests\package_manager\Kernel; +use Drupal\Component\Datetime\Time; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ModuleUninstallValidatorException; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageException; + /** * @coversDefaultClass \Drupal\package_manager\Stage * + * @covers \Drupal\package_manager\PackageManagerUninstallValidator + * * @group package_manager */ class StageTest extends PackageManagerKernelTestBase { + /** + * {@inheritdoc} + */ + protected static $modules = ['system']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig('system'); + $this->config('system.site')->set('uuid', $this->randomMachineName())->save(); + // Ensure that the core update system thinks that System's post-update + // functions have run. + $this->registerPostUpdateFunctions(); + } + + /** + * {@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'); + } + /** * @covers ::getStageDirectory + * @covers ::getStagingRoot */ public function testGetStageDirectory(): void { + // Ensure that a site ID was generated in ::setUp(). + $site_id = $this->config('system.site')->get('uuid'); + $this->assertNotEmpty($site_id); + $stage = $this->createStage(); $id = $stage->create(); - $this->assertStringEndsWith("/.package_manager/$id", $stage->getStageDirectory()); + $this->assertStringEndsWith("/.package_manager$site_id/$id", $stage->getStageDirectory()); + $stage->destroy(); + + $stage = $this->createStage(); + $another_id = $stage->create(); + // The new stage ID should be unique, but the parent directory should be + // unchanged. + $this->assertNotSame($id, $another_id); + $this->assertStringEndsWith("/.package_manager$site_id/$another_id", $stage->getStageDirectory()); } /** @@ -27,4 +83,150 @@ public function testUncreatedGetStageDirectory(): void { $this->createStage()->getStageDirectory(); } + /** + * Data provider for ::testDestroyDuringApply(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerDestroyDuringApply(): array { + return [ + 'force destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + TRUE, + 1, + TRUE, + ], + 'destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + FALSE, + 1, + TRUE, + ], + 'force destroy on pre-apply, stale' => [ + PreApplyEvent::class, + TRUE, + 7200, + FALSE, + ], + 'destroy on pre-apply, stale' => [ + PreApplyEvent::class, + FALSE, + 7200, + FALSE, + ], + 'force destroy on post-apply, fresh' => [ + PostApplyEvent::class, + TRUE, + 1, + TRUE, + ], + 'destroy on post-apply, fresh' => [ + PostApplyEvent::class, + FALSE, + 1, + TRUE, + ], + 'force destroy on post-apply, stale' => [ + PostApplyEvent::class, + TRUE, + 7200, + FALSE, + ], + 'destroy on post-apply, stale' => [ + PostApplyEvent::class, + FALSE, + 7200, + FALSE, + ], + ]; + } + + /** + * 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 bool $expect_exception + * Whether or not destroying the stage will raise an exception. + * + * @dataProvider providerDestroyDuringApply + */ + public function testDestroyDuringApply(string $event_class, bool $force, int $time_offset, bool $expect_exception): 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->getStage()->destroy($force); + }; + $this->container->get('event_dispatcher') + ->addListener($event_class, $listener); + + $stage = $this->createStage(); + $stage->create(); + if ($expect_exception) { + $this->expectException(StageException::class); + $this->expectExceptionMessage('Cannot destroy the staging area while it is being applied to the active directory.'); + } + $stage->apply(); + } + + /** + * Test uninstalling any module while the staged changes are being applied. + */ + public function testUninstallModuleDuringApply(): void { + $listener = function (PreApplyEvent $event): void { + $this->assertTrue($event->getStage()->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->container->get('event_dispatcher') + ->addListener(PreApplyEvent::class, $listener); + + $stage = $this->createStage(); + $stage->create(); + $stage->apply(); + } + +} + +/** + * 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; + } + } -- GitLab