diff --git a/core/modules/auto_updates/auto_updates.info.yml b/core/modules/auto_updates/auto_updates.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f3a5c27e24d7aa6395b0a900a32b4875088409f --- /dev/null +++ b/core/modules/auto_updates/auto_updates.info.yml @@ -0,0 +1,7 @@ +name: 'Automatic Updates' +type: module +description: 'Experimental module to develop automatic updates. Currently the module provides checks for update readiness but does not yet provide update functionality.' +core_version_requirement: ^9.2 +dependencies: + - drupal:package_manager + - drupal:update diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install new file mode 100644 index 0000000000000000000000000000000000000000..1923c8c5297f1407493e9b5c74a6796585904594 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.install @@ -0,0 +1,19 @@ +<?php + +/** + * @file + * Contains install and update functions for Automatic Updates. + */ + +use Drupal\auto_updates\Validation\ReadinessRequirements; + +/** + * Implements hook_requirements(). + */ +function auto_updates_requirements($phase) { + if ($phase === 'runtime') { + /** @var \Drupal\auto_updates\Validation\ReadinessRequirements $readiness_requirement */ + $readiness_requirement = \Drupal::classResolver(ReadinessRequirements::class); + return $readiness_requirement->getRequirements(); + } +} diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module new file mode 100644 index 0000000000000000000000000000000000000000..889160c787798a591ff1d5a6d44bf65cf8be745a --- /dev/null +++ b/core/modules/auto_updates/auto_updates.module @@ -0,0 +1,184 @@ +<?php + +/** + * @file + * Contains hook implementations for Automatic Updates. + */ + +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\update\ProjectSecurityData; + +/** + * Implements hook_page_top(). + */ +function auto_updates_page_top() { + /** @var \Drupal\auto_updates\Validation\AdminReadinessMessages $readiness_messages */ + $readiness_messages = \Drupal::classResolver(AdminReadinessMessages::class); + $readiness_messages->displayAdminPageMessages(); + + // @todo Rely on the route option after https://www.drupal.org/i/3236497 is + // committed. + // @todo Remove 'system.batch_page.html' after + // https://www.drupal.org/i/3238311 is committed. + $skip_routes = [ + 'system.batch_page.html', + 'auto_updates.confirmation_page', + 'auto_updates.report_update', + 'auto_updates.module_update', + ]; + $route_name = \Drupal::routeMatch()->getRouteName(); + if (!in_array($route_name, $skip_routes, TRUE) && function_exists('update_page_top')) { + update_page_top(); + } +} + +/** + * Implements hook_module_implements_alter(). + * + * @todo Remove after https://www.drupal.org/i/3236497 is committed. + */ +function auto_updates_module_implements_alter(&$implementations, $hook) { + if ($hook === 'page_top') { + // Remove hook_page_top() implementation from the Update module. This ' + // implementation displays error messages about security releases. We call + // this implementation in our own auto_updates_page_top() except on our + // own routes to avoid stale messages about the security releases after an + // update. + unset($implementations['update']); + } +} + +/** + * Implements hook_cron(). + */ +function auto_updates_cron() { + /** @var \Drupal\auto_updates\CronUpdater $cron_updater */ + $cron_updater = \Drupal::classResolver(CronUpdater::class); + $cron_updater->handleCron(); + + /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */ + $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager'); + $last_results = $checker_manager->getResults(); + $last_run_time = $checker_manager->getLastRunTime(); + // Do not run readiness checks more than once an hour unless there are no + // results available. + if ($last_results === NULL || !$last_run_time || \Drupal::time()->getRequestTime() - $last_run_time > 3600) { + $checker_manager->run(); + } + +} + +/** + * Implements hook_modules_installed(). + */ +function auto_updates_modules_installed() { + // Run the readiness checkers if needed when any modules are installed in + // case they provide readiness checker services. + /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */ + $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager'); + $checker_manager->runIfNoStoredResults(); +} + +/** + * Implements hook_modules_uninstalled(). + */ +function auto_updates_modules_uninstalled() { + // Run the readiness checkers if needed when any modules are uninstalled in + // case they provided readiness checker services. + /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */ + $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager'); + $checker_manager->runIfNoStoredResults(); +} + +/** + * Implements hook_form_FORM_ID_alter() for 'update_manager_update_form'. + */ +function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInterface $form_state, $form_id) { + // Remove current message that core updates are not supported with a link to + // use this modules form. + if (isset($form['manual_updates']['#rows']['drupal']['data']['title'])) { + $current_route = \Drupal::routeMatch()->getRouteName(); + if ($current_route === 'update.module_update') { + $redirect_route = 'auto_updates.module_update'; + } + elseif ($current_route === 'update.report_update') { + $redirect_route = 'auto_updates.report_update'; + } + if (!empty($redirect_route)) { + $core_updates_message = t( + '<h2>Core updates required</h2>Drupal core updates are supported by the enabled <a href="@url">Automatic Updates module</a>', + ['@url' => Url::fromRoute($redirect_route)->toString()] + ); + $form['manual_updates']['#prefix'] = $core_updates_message; + } + } +} + +/** + * 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']); + $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion(); + $supported_until_version = $version->getMajorVersion() . '.' + . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE) + . '.0'; + + $form['auto_updates_cron'] = [ + '#type' => 'radios', + '#title' => t('Automatically update Drupal core'), + '#options' => [ + CronUpdater::DISABLED => t('Disabled'), + CronUpdater::ALL => t('All supported updates'), + CronUpdater::SECURITY => t('Security updates only'), + ], + '#default_value' => \Drupal::config('auto_updates.settings')->get('cron'), + '#description' => t( + 'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.', + [ + '@current_minor' => $current_minor, + '@supported_until_version' => $supported_until_version, + ] + ), + ]; + $form += [ + '#submit' => ['::submitForm'], + ]; + $form['#submit'][] = '_auto_updates_update_settings_form_submit'; +} + +/** + * Submit function for the 'update_settings' form. + */ +function _auto_updates_update_settings_form_submit(array &$form, FormStateInterface $form_state) { + \Drupal::configFactory() + ->getEditable('auto_updates.settings') + ->set('cron', $form_state->getValue('auto_updates_cron')) + ->save(); +} + +/** + * Implements hook_local_tasks_alter(). + */ +function auto_updates_local_tasks_alter(array &$local_tasks) { + // The Update module's update form only allows updating modules and themes + // via archive files, which could produce unexpected results on a site using + // our Composer-based updater. + $new_routes = [ + 'update.report_update' => 'auto_updates.report_update', + 'update.module_update' => 'auto_updates.module_update', + 'update.theme_update' => 'auto_updates.theme_update', + ]; + foreach ($new_routes as $local_task_id => $new_route) { + if (!empty($local_tasks[$local_task_id])) { + $local_tasks[$local_task_id]['route_name'] = $new_route; + } + } +} diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..e3a5d1bb9d7c2d943b95106a8d530823f62f1662 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.routing.yml @@ -0,0 +1,44 @@ +auto_updates.update_readiness: + path: '/admin/auto_updates/readiness' + defaults: + _controller: '\Drupal\auto_updates\Controller\ReadinessCheckerController::run' + _title: 'Update readiness checking' + requirements: + _permission: 'administer software updates' +auto_updates.confirmation_page: + path: '/admin/automatic-update-ready/{stage_id}' + defaults: + _form: '\Drupal\auto_updates\Form\UpdateReady' + _title: 'Ready to update' + requirements: + _permission: 'administer software updates' + _access_update_manager: 'TRUE' +# Links to our updater form appear in two 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: + path: '/admin/reports/updates/automatic-update' + defaults: + _form: '\Drupal\auto_updates\Form\UpdaterForm' + _title: 'Update' + requirements: + _permission: 'administer software updates' + options: + _admin_route: TRUE +auto_updates.module_update: + path: '/admin/modules/automatic-update' + defaults: + _form: '\Drupal\auto_updates\Form\UpdaterForm' + _title: 'Update' + requirements: + _permission: 'administer software updates' + options: + _admin_route: TRUE +auto_updates.theme_update: + path: '/admin/theme/automatic-update' + defaults: + _form: '\Drupal\auto_updates\Form\UpdaterForm' + _title: 'Update' + requirements: + _permission: 'administer software updates' + options: + _admin_route: TRUE diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..d2b80b6847de8efaf6d1003f62689300486728b7 --- /dev/null +++ b/core/modules/auto_updates/auto_updates.services.yml @@ -0,0 +1,76 @@ +services: + auto_updates.readiness_validation_manager: + class: Drupal\auto_updates\Validation\ReadinessValidationManager + arguments: + - '@keyvalue.expirable' + - '@datetime.time' + - '@package_manager.path_locator' + - '@event_dispatcher' + - '@auto_updates.updater' + - 24 + auto_updates.updater: + class: Drupal\auto_updates\Updater + arguments: + - '@package_manager.path_locator' + - '@package_manager.beginner' + - '@package_manager.stager' + - '@package_manager.committer' + - '@file_system' + - '@event_dispatcher' + - '@tempstore.shared' + auto_updates.excluded_paths_subscriber: + class: Drupal\auto_updates\Event\ExcludedPathsSubscriber + arguments: + - '@extension.list.module' + tags: + - { name: event_subscriber } + auto_updates.staged_projects_validator: + class: Drupal\auto_updates\Validator\StagedProjectsValidator + arguments: + - '@string_translation' + tags: + - { name: event_subscriber } + auto_updates.update_version_validator: + class: Drupal\auto_updates\Validator\UpdateVersionValidator + arguments: + - '@string_translation' + tags: + - { name: event_subscriber } + auto_updates.composer_executable_validator: + class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck + arguments: + - '@package_manager.validator.composer_executable' + tags: + - { name: event_subscriber } + auto_updates.disk_space_validator: + class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck + arguments: + - '@package_manager.validator.disk_space' + tags: + - { name: event_subscriber } + auto_updates.pending_updates_validator: + class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck + arguments: + - '@package_manager.validator.pending_updates' + tags: + - { name: event_subscriber } + auto_updates.validator.file_system_permissions: + class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck + arguments: + - '@package_manager.validator.file_system' + tags: + - { name: event_subscriber } + auto_updates.validator.core_composer: + class: Drupal\auto_updates\Validator\CoreComposerValidator + tags: + - { name: event_subscriber } + auto_updates.cron_frequency_validator: + class: Drupal\auto_updates\Validator\CronFrequencyValidator + arguments: + - '@config.factory' + - '@module_handler' + - '@state' + - '@datetime.time' + - '@string_translation' + tags: + - { name: event_subscriber } diff --git a/core/modules/auto_updates/composer.json b/core/modules/auto_updates/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..1dfb4b9a7b6c60bd8aebb646a26db00b8175a6b5 --- /dev/null +++ b/core/modules/auto_updates/composer.json @@ -0,0 +1,30 @@ +{ + "name": "drupal/auto_updates", + "type": "drupal-module", + "description": "Drupal Automatic Updates", + "keywords": ["Drupal"], + "license": "GPL-2.0-or-later", + "homepage": "https://www.drupal.org/project/auto_updates", + "minimum-stability": "dev", + "support": { + "issues": "https://www.drupal.org/project/issues/auto_updates", + "source": "http://cgit.drupalcode.org/auto_updates" + }, + "require": { + "ext-json": "*", + "drupal/core": "^9.2", + "php-tuf/composer-stager": "0.2.3", + "composer/composer": "^2" + }, + "config": { + "platform": { + "php": "7.3.0" + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/php-tuf/composer-stager" + } + ] +} diff --git a/core/modules/auto_updates/config/install/auto_updates.settings.yml b/core/modules/auto_updates/config/install/auto_updates.settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..207c746331023223ff9ccc8c31f663e512e3a2d0 --- /dev/null +++ b/core/modules/auto_updates/config/install/auto_updates.settings.yml @@ -0,0 +1 @@ +cron: security diff --git a/core/modules/auto_updates/config/schema/auto_updates.schema.yml b/core/modules/auto_updates/config/schema/auto_updates.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe97866c9319b9b64df82735801eb46bfe49e6ab --- /dev/null +++ b/core/modules/auto_updates/config/schema/auto_updates.schema.yml @@ -0,0 +1,7 @@ +auto_updates.settings: + type: config_object + label: 'Automatic Updates settings' + mapping: + cron: + type: string + label: 'Enable automatic updates during cron' diff --git a/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml b/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..bbe571a9cbae09a0a16aae99c6342585cdb0d2d6 --- /dev/null +++ b/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml @@ -0,0 +1,4 @@ +file_syncer: php +executables: + composer: ~ + rsync: ~ diff --git a/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml b/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d029371777a7d3a762d2d17dda2a01a3ccf03f2 --- /dev/null +++ b/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml @@ -0,0 +1,13 @@ +package_manager.settings: + type: config_object + label: 'Package Manager settings' + mapping: + file_syncer: + type: string + label: 'Which file syncer to use, or NULL to auto-detect' + executables: + type: sequence + label: 'Absolute paths to required executables, or NULL to rely on PATH' + sequence: + type: string + label: 'Absolute path to executable, or NULL' diff --git a/core/modules/auto_updates/package_manager/core_packages.json b/core/modules/auto_updates/package_manager/core_packages.json new file mode 100644 index 0000000000000000000000000000000000000000..1804b96aef831caadf38f3b2d2887d51e233c88b --- /dev/null +++ b/core/modules/auto_updates/package_manager/core_packages.json @@ -0,0 +1,9 @@ +[ + "drupal/core", + "drupal/core-composer-scaffold", + "drupal/core-dev", + "drupal/core-dev-pinned", + "drupal/core-project-message", + "drupal/core-recommended", + "drupal/core-vendor-hardening" +] diff --git a/core/modules/auto_updates/package_manager/package_manager.info.yml b/core/modules/auto_updates/package_manager/package_manager.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfb5b5ffe2df25be41737a8ce1c2bcbc7d154316 --- /dev/null +++ b/core/modules/auto_updates/package_manager/package_manager.info.yml @@ -0,0 +1,4 @@ +name: 'Package Manager' +type: module +description: 'API module providing functionality for staging package updates.' +core_version_requirement: ^9 diff --git a/core/modules/auto_updates/package_manager/package_manager.install b/core/modules/auto_updates/package_manager/package_manager.install new file mode 100644 index 0000000000000000000000000000000000000000..0793ddea7a27e2cef93be3aae26f01de6d642bec --- /dev/null +++ b/core/modules/auto_updates/package_manager/package_manager.install @@ -0,0 +1,20 @@ +<?php + +/** + * @file + * Contains install and update functions for Package Manager. + */ + +/** + * Implements hook_requirements(). + */ +function package_manager_requirements() { + if (!class_exists('PhpTuf\ComposerStager\Domain\Beginner')) { + return [ + 'package_manager' => [ + 'description' => t('External dependencies for Package Manager are not available. Composer must be used to download the module with dependencies.'), + 'severity' => REQUIREMENT_ERROR, + ], + ]; + } +} diff --git a/core/modules/auto_updates/package_manager/package_manager.services.yml b/core/modules/auto_updates/package_manager/package_manager.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..803c02b7c61ffdd2c92efc86d5bdec5fd83e9f66 --- /dev/null +++ b/core/modules/auto_updates/package_manager/package_manager.services.yml @@ -0,0 +1,130 @@ +services: + # Underlying Symfony utilities. + package_manager.symfony_file_system: + class: Symfony\Component\Filesystem\Filesystem + package_manager.symfony_executable_finder: + class: Symfony\Component\Process\ExecutableFinder + package_manager.symfony_finder: + class: Symfony\Component\Finder\Finder + + # Basic infrastructure services. + package_manager.process_factory: + class: Drupal\package_manager\ProcessFactory + arguments: + - '@file_system' + - '@config.factory' + package_manager.file_system: + class: PhpTuf\ComposerStager\Infrastructure\Filesystem\Filesystem + arguments: + - '@package_manager.symfony_file_system' + package_manager.executable_finder: + class: Drupal\package_manager\ExecutableFinder + arguments: + - '@package_manager.symfony_executable_finder' + - '@config.factory' + + # Executable runners. + package_manager.rsync_runner: + class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\RsyncRunner + arguments: + - '@package_manager.executable_finder' + - '@package_manager.process_factory' + package_manager.composer_runner: + class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunner + arguments: + - '@package_manager.executable_finder' + - '@package_manager.process_factory' + + # File syncers. + package_manager.file_syncer.rsync: + class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\RsyncFileSyncer + arguments: + - '@package_manager.file_system' + - '@package_manager.rsync_runner' + package_manager.file_syncer.php: + class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\PhpFileSyncer + arguments: + - '@package_manager.file_system' + - '@package_manager.symfony_finder' + - '@package_manager.symfony_finder' + package_manager.file_syncer.factory: + class: Drupal\package_manager\FileSyncerFactory + arguments: + - '@package_manager.symfony_executable_finder' + - '@package_manager.file_syncer.php' + - '@package_manager.file_syncer.rsync' + - '@config.factory' + package_manager.file_syncer: + class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + factory: ['@package_manager.file_syncer.factory', 'create'] + + # Domain services. + package_manager.beginner: + class: PhpTuf\ComposerStager\Domain\Beginner + arguments: + - '@package_manager.file_syncer' + - '@package_manager.file_system' + package_manager.stager: + class: PhpTuf\ComposerStager\Domain\Stager + arguments: + - '@package_manager.composer_runner' + - '@package_manager.file_system' + package_manager.committer: + class: PhpTuf\ComposerStager\Domain\Committer + arguments: + - '@package_manager.file_syncer' + - '@package_manager.file_system' + package_manager.path_locator: + class: Drupal\package_manager\PathLocator + arguments: + - '%app.root%' + + # Validators. + package_manager.validator.composer_executable: + class: Drupal\package_manager\EventSubscriber\ComposerExecutableValidator + arguments: + - '@package_manager.composer_runner' + - '@string_translation' + tags: + - { name: event_subscriber } + package_manager.validator.disk_space: + class: Drupal\package_manager\EventSubscriber\DiskSpaceValidator + arguments: + - '@package_manager.path_locator' + - '@string_translation' + tags: + - { name: event_subscriber } + package_manager.validator.pending_updates: + class: Drupal\package_manager\EventSubscriber\PendingUpdatesValidator + arguments: + - '%app.root%' + - '@update.post_update_registry' + - '@string_translation' + tags: + - { name: event_subscriber } + package_manager.validator.lock_file: + class: Drupal\package_manager\EventSubscriber\LockFileValidator + arguments: + - '@state' + - '@package_manager.path_locator' + - '@string_translation' + tags: + - { name: event_subscriber } + package_manager.validator.file_system: + class: Drupal\package_manager\EventSubscriber\WritableFileSystemValidator + arguments: + - '@package_manager.path_locator' + - '%app.root%' + - '@string_translation' + tags: + - { name: event_subscriber } + package_manager.excluded_paths_subscriber: + class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber + arguments: + - '%app.root%' + - '%site.path%' + - '@file_system' + - '@stream_wrapper_manager' + - '@database' + tags: + - { name: event_subscriber } diff --git a/core/modules/auto_updates/package_manager/src/ComposerUtility.php b/core/modules/auto_updates/package_manager/src/ComposerUtility.php new file mode 100644 index 0000000000000000000000000000000000000000..6460b9e13c2aea78085a0468ae5220deb0333b4a --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/ComposerUtility.php @@ -0,0 +1,177 @@ +<?php + +namespace Drupal\package_manager; + +use Composer\Composer; +use Composer\Factory; +use Composer\IO\NullIO; +use Composer\Package\PackageInterface; +use Drupal\Component\Serialization\Json; + +/** + * Defines a utility object to get information from Composer's API. + */ +class ComposerUtility { + + /** + * The Composer instance. + * + * @var \Composer\Composer + */ + protected $composer; + + /** + * The statically cached names of the Drupal core packages. + * + * @var string[] + */ + private static $corePackages; + + /** + * Constructs a new ComposerUtility object. + * + * @param \Composer\Composer $composer + * The Composer instance. + */ + public function __construct(Composer $composer) { + $this->composer = $composer; + } + + /** + * Creates a utility object using the files in a given directory. + * + * @param string $dir + * The directory that contains composer.json and composer.lock. + * + * @return \Drupal\package_manager\ComposerUtility + * The utility object. + */ + public static function createForDirectory(string $dir): self { + $io = new NullIO(); + $configuration = $dir . DIRECTORY_SEPARATOR . 'composer.json'; + + // The Composer factory requires that either the HOME or COMPOSER_HOME + // environment variables be set, so momentarily set the COMPOSER_HOME + // variable to the directory we're trying to create a Composer instance for. + // We have to do this because the Composer factory doesn't give us a way to + // pass the home directory in. + // @see \Composer\Factory::getHomeDir() + $home = getenv('COMPOSER_HOME'); + // Disable the automatic generation of .htaccess files in the Composer home + // directory, since we are temporarily overriding that directory. + // @see \Composer\Factory::createConfig() + // @see https://getcomposer.org/doc/06-config.md#htaccess-protect + $htaccess = getenv('COMPOSER_HTACCESS_PROTECT'); + + putenv("COMPOSER_HOME=$dir"); + putenv("COMPOSER_HTACCESS_PROTECT=false"); + $composer = Factory::create($io, $configuration); + putenv("COMPOSER_HOME=$home"); + putenv("COMPOSER_HTACCESS_PROTECT=$htaccess"); + + return new static($composer); + } + + /** + * Returns the canonical names of the supported core packages. + * + * @return string[] + * The canonical list of supported core package names, as listed in + * ../core_packages.json. + */ + protected static function getCorePackageList(): array { + if (self::$corePackages === NULL) { + $file = __DIR__ . '/../core_packages.json'; + assert(file_exists($file), "$file does not exist."); + + $core_packages = file_get_contents($file); + $core_packages = Json::decode($core_packages); + + assert(is_array($core_packages), "$file did not contain a list of core packages."); + self::$corePackages = $core_packages; + } + return self::$corePackages; + } + + /** + * Returns the names of the core packages in the lock file. + * + * All packages listed in ../core_packages.json are considered core packages. + * + * @return string[] + * The names of the required core packages. + * + * @todo Make this return a keyed array of packages, not just names. + */ + public function getCorePackageNames(): array { + $core_packages = array_intersect( + array_keys($this->getLockedPackages()), + static::getCorePackageList() + ); + + // If drupal/core-recommended is present, it supersedes drupal/core, since + // drupal/core will always be one of its direct dependencies. + if (in_array('drupal/core-recommended', $core_packages, TRUE)) { + $core_packages = array_diff($core_packages, ['drupal/core']); + } + return array_values($core_packages); + } + + /** + * Returns the names of the core packages in the dev dependencies. + * + * All packages listed in ../core_packages.json are considered core packages. + * + * @return string[] + * The names of the core packages in the dev requirements. + * + * @todo Make this return a keyed array of packages, not just names. + */ + public function getCoreDevPackageNames(): array { + $dev_packages = $this->composer->getPackage()->getDevRequires(); + $dev_packages = array_keys($dev_packages); + return array_intersect(static::getCorePackageList(), $dev_packages); + } + + /** + * Returns all Drupal extension packages in the lock file. + * + * The following package types are considered Drupal extension packages: + * drupal-module, drupal-theme, drupal-custom-module, and drupal-custom-theme. + * + * @return \Composer\Package\PackageInterface[] + * All Drupal extension packages in the lock file, keyed by name. + */ + public function getDrupalExtensionPackages(): array { + $filter = function (PackageInterface $package): bool { + $drupal_package_types = [ + 'drupal-module', + 'drupal-theme', + 'drupal-custom-module', + 'drupal-custom-theme', + ]; + return in_array($package->getType(), $drupal_package_types, TRUE); + }; + return array_filter($this->getLockedPackages(), $filter); + } + + /** + * Returns all packages in the lock file. + * + * @return \Composer\Package\PackageInterface[] + * All packages in the lock file, keyed by name. + */ + protected function getLockedPackages(): array { + $locked_packages = $this->composer->getLocker() + ->getLockedRepository(TRUE) + ->getPackages(); + + $packages = []; + foreach ($locked_packages as $package) { + $key = $package->getName(); + $packages[$key] = $package; + } + return $packages; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php b/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..3648446b80f2049d5f9dec6abcb77048a82d41fe --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Common functionality for events which can collect excluded paths. + */ +trait ExcludedPathsTrait { + + /** + * Paths to exclude from the update. + * + * @var string[] + */ + protected $excludedPaths = []; + + /** + * Adds an absolute path to exclude from the current operation. + * + * @todo This should only accept paths relative to the active directory. + * + * @param string $path + * The path to exclude. + */ + public function excludePath(string $path): void { + $this->excludedPaths[] = $path; + } + + /** + * Returns the paths to exclude from the current operation. + * + * @return string[] + * The paths to exclude. + */ + public function getExcludedPaths(): array { + return array_unique($this->excludedPaths); + } + +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..d2a9693cf9c3ee99970538fdbf404564c80309ac --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired after staged changes are synced to the active directory. + */ +class PostApplyEvent extends StageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..104bc78ad6ff0681c83ad3f494ee8e309c87f7e9 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired after a staging area has been created. + */ +class PostCreateEvent extends StageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..3388a988e126da919dfbc598946910f38b86cbbe --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired after the staging area is destroyed. + */ +class PostDestroyEvent extends StageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..55b4184dd3d96daaeeba55317c227fd0bbfb089c --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired after packages are added to the staging area. + */ +class PostRequireEvent extends StageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..d92a116b4a0fc50e279f53ae80c5dd56863484b5 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired before staged changes are synced to the active directory. + */ +class PreApplyEvent extends PreOperationStageEvent { + + use ExcludedPathsTrait; + +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..3997a80fc3b5f611ce73b929fe815f88efd8c5d1 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired before a staging area is created. + */ +class PreCreateEvent extends PreOperationStageEvent { + + use ExcludedPathsTrait; + +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..d4918f0b8b75c96a1b62a141b029c086996a3f54 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired before the staging area is destroyed. + */ +class PreDestroyEvent extends PreOperationStageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..ee826f3ac35cdfe6f3a987112786d16a666b7ff9 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\package_manager\Event; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\ValidationResult; + +/** + * Base class for events dispatched before a stage life cycle operation. + */ +abstract class PreOperationStageEvent extends StageEvent { + + /** + * {@inheritdoc} + */ + public function addError(array $messages, ?TranslatableMarkup $summary = NULL) { + $this->results[] = ValidationResult::createError($messages, $summary); + } + +} diff --git a/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..355bb5047fb89719c0b0711db51d675be0368387 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Event; + +/** + * Event fired before packages are added to the staging area. + */ +class PreRequireEvent extends PreOperationStageEvent { +} diff --git a/core/modules/auto_updates/package_manager/src/Event/StageEvent.php b/core/modules/auto_updates/package_manager/src/Event/StageEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..79a26a5ac3c2c430ebcce2d4f5f5262fcf6ce60e --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Event/StageEvent.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\package_manager\Event; + +use Drupal\package_manager\Stage; +use Symfony\Contracts\EventDispatcher\Event; + +/** + * Base class for all events related to the life cycle of the staging area. + */ +abstract class StageEvent extends Event { + + /** + * The validation results. + * + * @var \Drupal\package_manager\ValidationResult[] + */ + protected $results = []; + + /** + * The stage which fired this event. + * + * @var \Drupal\package_manager\Stage + */ + protected $stage; + + /** + * Constructs a StageEvent object. + * + * @param \Drupal\package_manager\Stage $stage + * The stage which fired this event. + */ + public function __construct(Stage $stage) { + $this->stage = $stage; + } + + /** + * Returns the stage which fired this event. + * + * @return \Drupal\package_manager\Stage + * The stage which fired this event. + */ + public function getStage(): Stage { + return $this->stage; + } + + /** + * Gets the validation results. + * + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return \Drupal\package_manager\ValidationResult[] + * The validation results. + */ + public function getResults(?int $severity = NULL): array { + if ($severity !== NULL) { + return array_filter($this->results, function ($result) use ($severity) { + return $result->getSeverity() === $severity; + }); + } + return $this->results; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..a4df9a35915540e9fbf3e644bd5c58f24adaaed4 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php @@ -0,0 +1,101 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +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 PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface; + +/** + * Validates that the Composer executable can be found in the correct version. + */ +class ComposerExecutableValidator implements PreOperationStageValidatorInterface, ProcessOutputCallbackInterface { + + use StringTranslationTrait; + + /** + * The Composer runner. + * + * @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface + */ + protected $composer; + + /** + * The detected version of Composer. + * + * @var string + */ + protected $version; + + /** + * Constructs a ComposerExecutableValidator object. + * + * @param \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface $composer + * The Composer runner. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(ComposerRunnerInterface $composer, TranslationInterface $translation) { + $this->composer = $composer; + $this->setStringTranslation($translation); + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + try { + $this->composer->run(['--version'], $this); + } + catch (ExceptionInterface $e) { + $event->addError([ + $e->getMessage(), + ]); + return; + } + + if ($this->version) { + $major_version = ExtensionVersion::createFromVersionString($this->version) + ->getMajorVersion(); + + if ($major_version < 2) { + $event->addError([ + $this->t('Composer 2 or later is required, but version @version was detected.', [ + '@version' => $this->version, + ]), + ]); + } + } + else { + $event->addError([ + $this->t('The Composer version could not be detected.'), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + ]; + } + + /** + * {@inheritdoc} + */ + public function __invoke(string $type, string $buffer): void { + $matched = []; + // Search for a semantic version number and optional stability flag. + if (preg_match('/([0-9]+\.?){3}-?((alpha|beta|rc)[0-9]*)?/i', $buffer, $matched)) { + $this->version = $matched[0]; + } + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..c54a653052092cb6f5f6de96292ac8178fea9a0b --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php @@ -0,0 +1,165 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\Bytes; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\PathLocator; + +/** + * Validates that there is enough free disk space to do automatic updates. + */ +class DiskSpaceValidator implements PreOperationStageValidatorInterface { + + use StringTranslationTrait; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a DiskSpaceValidator object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(PathLocator $path_locator, TranslationInterface $translation) { + $this->pathLocator = $path_locator; + $this->setStringTranslation($translation); + } + + /** + * Wrapper around the disk_free_space() function. + * + * @param string $path + * The path for which to retrieve the amount of free disk space. + * + * @return float + * The number of bytes of free space on the disk. + * + * @throws \RuntimeException + * If the amount of free space could not be determined. + */ + protected function freeSpace(string $path): float { + $free_space = disk_free_space($path); + if ($free_space === FALSE) { + throw new \RuntimeException("Cannot get disk information for $path."); + } + return $free_space; + } + + /** + * Wrapper around the stat() function. + * + * @param string $path + * The path to check. + * + * @return array + * The statistics for the path. + * + * @throws \RuntimeException + * If the statistics could not be determined. + */ + protected function stat(string $path): array { + $stat = stat($path); + if ($stat === FALSE) { + throw new \RuntimeException("Cannot get information for $path."); + } + return $stat; + } + + /** + * Checks if two paths are located on the same logical disk. + * + * @param string $root + * The path of the project root. + * @param string $vendor + * The path of the vendor directory. + * + * @return bool + * TRUE if the project root and vendor directory are on the same logical + * disk, FALSE otherwise. + */ + protected function areSameLogicalDisk(string $root, string $vendor): bool { + $root_statistics = $this->stat($root); + $vendor_statistics = $this->stat($vendor); + return $root_statistics['dev'] === $vendor_statistics['dev']; + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + $root_path = $this->pathLocator->getProjectRoot(); + $vendor_path = $this->pathLocator->getVendorDirectory(); + $messages = []; + + // @todo Make this configurable. + $minimum_mb = 1024; + $minimum_bytes = Bytes::toNumber($minimum_mb . 'M'); + + if (!$this->areSameLogicalDisk($root_path, $vendor_path)) { + if ($this->freeSpace($root_path) < $minimum_bytes) { + $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $root_path, + '@space' => $minimum_mb, + ]); + } + if (is_dir($vendor_path) && $this->freeSpace($vendor_path) < $minimum_bytes) { + $messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [ + '@vendor' => $vendor_path, + '@space' => $minimum_mb, + ]); + } + } + elseif ($this->freeSpace($root_path) < $minimum_bytes) { + $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $root_path, + '@space' => $minimum_mb, + ]); + } + $temp = $this->temporaryDirectory(); + if ($this->freeSpace($temp) < $minimum_bytes) { + $messages[] = $this->t('Directory "@temp" has insufficient space. There must be at least @space megabytes free.', [ + '@temp' => $temp, + '@space' => $minimum_mb, + ]); + } + + if ($messages) { + $summary = count($messages) > 1 + ? $this->t("There is not enough disk space to create a staging area.") + : NULL; + $event->addError($messages, $summary); + } + } + + /** + * Returns the path of the system temporary directory. + * + * @return string + * The absolute path of the system temporary directory. + */ + protected function temporaryDirectory(): string { + return FileSystem::getOsTemporaryDirectory(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..4663284c7963ea7122f06401de72ff76723fa7e0 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php @@ -0,0 +1,173 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\Database\Connection; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\StreamWrapper\LocalStream; +use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber to exclude certain paths from staging areas. + */ +class ExcludedPathsSubscriber implements EventSubscriberInterface { + + /** + * The Drupal root. + * + * @var string + */ + protected $appRoot; + + /** + * The current site path, relative to the Drupal root. + * + * @var string + */ + protected $sitePath; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + protected $fileSystem; + + /** + * The stream wrapper manager service. + * + * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface + */ + protected $streamWrapperManager; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * Constructs an ExcludedPathsSubscriber. + * + * @param string $app_root + * The Drupal root. + * @param string $site_path + * The current site path, relative to the Drupal root. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. + * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager + * The stream wrapper manager service. + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + public function __construct(string $app_root, string $site_path, FileSystemInterface $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database) { + $this->appRoot = $app_root; + $this->sitePath = $site_path; + $this->fileSystem = $file_system; + $this->streamWrapperManager = $stream_wrapper_manager; + $this->database = $database; + } + + /** + * Reacts before staged changes are committed the active directory. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function preApply(PreApplyEvent $event): void { + // Don't copy anything from the staging area's sites/default. + // @todo Make this a lot smarter in https://www.drupal.org/i/3228955. + $event->excludePath('sites/default'); + + // If the core-vendor-hardening plugin (used in the legacy-project template) + // is present, it may have written a web.config file into the vendor + // directory. We don't want to copy that. + $event->excludePath('web.config'); + } + + /** + * Excludes paths from a staging area before it is created. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event object. + */ + public function preCreate(PreCreateEvent $event): void { + // Automated test site directories should never be staged. + $event->excludePath('sites/simpletest'); + + // Windows server configuration files, like web.config, should never be + // staged either. (These can be written in the vendor directory by the + // core-vendor-hardening plugin, which is used in the drupal/legacy-project + // template.) + $event->excludePath('web.config'); + + if ($public = $this->getFilesPath('public')) { + $event->excludePath($public); + } + if ($private = $this->getFilesPath('private')) { + $event->excludePath($private); + } + + // Exclude site-specific settings files. + $settings_files = [ + 'settings.php', + 'settings.local.php', + 'services.yml', + ]; + $default_site = 'sites' . DIRECTORY_SEPARATOR . 'default'; + + foreach ($settings_files as $settings_file) { + $event->excludePath($this->sitePath . DIRECTORY_SEPARATOR . $settings_file); + $event->excludePath($default_site . DIRECTORY_SEPARATOR . $settings_file); + } + + // If the database is SQLite, it might be located in the active directory + // and we should not stage it. + if ($this->database->driver() === 'sqlite') { + $options = $this->database->getConnectionOptions(); + $database = str_replace($this->appRoot, NULL, $options['database']); + $database = ltrim($database, '/'); + $event->excludePath($database); + $event->excludePath("$database-shm"); + $event->excludePath("$database-wal"); + } + } + + /** + * Returns the storage path for a stream wrapper. + * + * This will only work for stream wrappers that extend + * \Drupal\Core\StreamWrapper\LocalStream, which includes the stream wrappers + * for public and private files. + * + * @param string $scheme + * The stream wrapper scheme. + * + * @return string|null + * The storage path for files using the given scheme, relative to the Drupal + * root, or NULL if the stream wrapper does not extend + * \Drupal\Core\StreamWrapper\LocalStream. + */ + private function getFilesPath(string $scheme): ?string { + $wrapper = $this->streamWrapperManager->getViaScheme($scheme); + if ($wrapper instanceof LocalStream) { + return $wrapper->getDirectoryPath(); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'preCreate', + PreApplyEvent::class => 'preApply', + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..dab3d8d298bbff2e71830ca55218b8cff4bfad8a --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php @@ -0,0 +1,142 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\PathLocator; + +/** + * Checks that the active lock file is unchanged during stage operations. + */ +class LockFileValidator implements PreOperationStageValidatorInterface { + + use StringTranslationTrait; + + /** + * The state key under which to store the hash of the active lock file. + * + * @var string + */ + protected const STATE_KEY = 'package_manager.lock_hash'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * Constructs a LockFileValidator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation service. + */ + public function __construct(StateInterface $state, PathLocator $path_locator, TranslationInterface $translation) { + $this->state = $state; + $this->pathLocator = $path_locator; + $this->setStringTranslation($translation); + } + + /** + * Returns the current hash of the active directory's lock file. + * + * @return string|false + * The hash of the active directory's lock file, or FALSE if the lock file + * does not exist. + */ + protected function getHash() { + $file = $this->pathLocator->getActiveDirectory() . 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 + // have not changed; we don't care about the constraints in composer.json. + try { + return hash_file('sha256', $file); + } + catch (\Throwable $exception) { + return FALSE; + } + } + + /** + * Stores the current lock file hash. + */ + public function storeHash(PreCreateEvent $event): void { + $hash = $this->getHash(); + if ($hash) { + $this->state->set(static::STATE_KEY, $hash); + } + else { + $event->addError([ + $this->t('Could not hash the active lock file.'), + ]); + } + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + // Ensure we can get a current hash of the lock file. + $hash = $this->getHash(); + if (empty($hash)) { + $error = $this->t('Could not hash the active lock file.'); + } + + // Ensure we also have a stored hash of the lock file. + $stored_hash = $this->state->get(static::STATE_KEY); + if (empty($stored_hash)) { + $error = $this->t('Could not retrieve stored hash of the active lock file.'); + } + + // If we have both hashes, ensure they match. + if ($hash && $stored_hash && hash_equals($stored_hash, $hash) == FALSE) { + $error = $this->t('Stored lock file hash does not match the active lock file.'); + } + + // @todo Let the validation result carry all the relevant messages in + // https://www.drupal.org/i/3247479. + if (isset($error)) { + $event->addError([$error]); + } + } + + /** + * Deletes the stored lock file hash. + */ + public function deleteHash(): void { + $this->state->delete(static::STATE_KEY); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'storeHash', + PreRequireEvent::class => 'validateStagePreOperation', + PreApplyEvent::class => 'validateStagePreOperation', + PostDestroyEvent::class => 'deleteHash', + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..53cbc7420633dc26bc0485072ae11b0ede02ce77 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\Update\UpdateRegistry; +use Drupal\Core\Url; + +/** + * Validates that there are no pending database updates. + */ +class PendingUpdatesValidator implements PreOperationStageValidatorInterface { + + use StringTranslationTrait; + + /** + * The Drupal root. + * + * @var string + */ + protected $appRoot; + + /** + * The update registry service. + * + * @var \Drupal\Core\Update\UpdateRegistry + */ + protected $updateRegistry; + + /** + * Constructs an PendingUpdatesValidator object. + * + * @param string $app_root + * The Drupal root. + * @param \Drupal\Core\Update\UpdateRegistry $update_registry + * The update registry service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(string $app_root, UpdateRegistry $update_registry, TranslationInterface $translation) { + $this->appRoot = $app_root; + $this->updateRegistry = $update_registry; + $this->setStringTranslation($translation); + } + + /** + * {@inheritdoc} + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + require_once $this->appRoot . '/core/includes/install.inc'; + require_once $this->appRoot . '/core/includes/update.inc'; + + drupal_load_updates(); + $hook_updates = update_get_update_list(); + $post_updates = $this->updateRegistry->getPendingUpdateFunctions(); + + if ($hook_updates || $post_updates) { + $message = $this->t('Some modules have database schema updates to install. You should run the <a href=":update">database update script</a> immediately.', [ + ':update' => Url::fromRoute('system.db_update')->toString(), + ]); + $event->addError([$message]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..a48ff5127f42a5e5ce0c5af75b4c4e4319479013 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php @@ -0,0 +1,21 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PreOperationStageEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an interface for classes that validate a stage before an operation. + */ +interface PreOperationStageValidatorInterface extends EventSubscriberInterface { + + /** + * Validates a stage before an operation. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The stage event. + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void; + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..5b422b4be7621b56306dfc1d7678cf7aa3633b26 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\update\UpdateManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Clears stale update data once staged changes have been applied. + */ +class UpdateDataSubscriber implements EventSubscriberInterface { + + /** + * The update manager service. + * + * @var \Drupal\update\UpdateManagerInterface + */ + protected $updateManager; + + /** + * Constructs an UpdateRefreshSubscriber object. + * + * @param \Drupal\update\UpdateManagerInterface $update_manager + * The update manager service. + */ + public function __construct(UpdateManagerInterface $update_manager) { + $this->updateManager = $update_manager; + } + + /** + * Clears stale update data. + * + * This will always run after any staging area is applied to the active + * directory, since it's likely that core and/or multiple extensions have been + * added, removed, or updated. + */ + public function clearData(): void { + $this->updateManager->refreshUpdateData(); + update_storage_clear(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PostApplyEvent::class => ['clearData', 1000], + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..95bc5d1104deac380383c2c28d996d6f928c1320 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php @@ -0,0 +1,84 @@ +<?php + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\PathLocator; + +/** + * Checks that the file system is writable. + */ +class WritableFileSystemValidator implements PreOperationStageValidatorInterface { + + use StringTranslationTrait; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * The Drupal root. + * + * @var string + */ + protected $appRoot; + + /** + * Constructs a WritableFileSystemValidator object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param string $app_root + * The Drupal root. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(PathLocator $path_locator, string $app_root, TranslationInterface $translation) { + $this->pathLocator = $path_locator; + $this->appRoot = $app_root; + $this->setStringTranslation($translation); + } + + /** + * {@inheritdoc} + * + * @todo It might make sense to use a more sophisticated method of testing + * writability than is_writable(), since it's not clear if that can return + * false negatives/positives due to things like SELinux, exotic file + * systems, and so forth. + */ + public function validateStagePreOperation(PreOperationStageEvent $event): void { + $messages = []; + + if (!is_writable($this->appRoot)) { + $messages[] = $this->t('The Drupal directory "@dir" is not writable.', [ + '@dir' => $this->appRoot, + ]); + } + + $dir = $this->pathLocator->getVendorDirectory(); + if (!is_writable($dir)) { + $messages[] = $this->t('The vendor directory "@dir" is not writable.', ['@dir' => $dir]); + } + + if ($messages) { + $event->addError($messages, $this->t('The file system is not writable.')); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'validateStagePreOperation', + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageException.php b/core/modules/auto_updates/package_manager/src/Exception/StageException.php new file mode 100644 index 0000000000000000000000000000000000000000..e8011bac34bddd19e135892215021de23991b8e5 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Exception/StageException.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Exception; + +/** + * Base class for all exceptions related to staging operations. + */ +class StageException extends \RuntimeException { +} diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php b/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php new file mode 100644 index 0000000000000000000000000000000000000000..8a8e868ac99b11598727436e45313aa44f0b2e1c --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php @@ -0,0 +1,9 @@ +<?php + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if a stage encounters an ownership or locking error. + */ +class StageOwnershipException extends StageException { +} diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php b/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php new file mode 100644 index 0000000000000000000000000000000000000000..1bb0a9c6960f20412b527ca4857c43d304f28233 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\package_manager\Exception; + +/** + * Exception thrown if a stage has validation errors. + */ +class StageValidationException extends StageException { + + /** + * Any relevant validation results. + * + * @var \Drupal\package_manager\ValidationResult[] + */ + protected $results = []; + + /** + * Constructs a StageException object. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * Any relevant validation results. + * @param mixed ...$arguments + * Arguments to pass to the parent constructor. + */ + public function __construct(array $results = [], ...$arguments) { + $this->results = $results; + parent::__construct(...$arguments); + } + + /** + * Gets the validation results. + * + * @return \Drupal\package_manager\ValidationResult[] + * The validation results. + */ + public function getResults(): array { + return $this->results; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/ExecutableFinder.php b/core/modules/auto_updates/package_manager/src/ExecutableFinder.php new file mode 100644 index 0000000000000000000000000000000000000000..d18f45be1327de05370db1c6cfefd8e3d2254c4a --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/ExecutableFinder.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface; +use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder as StagerExecutableFinder; +use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder; + +/** + * An executable finder which looks for executable paths in configuration. + */ +class ExecutableFinder implements ExecutableFinderInterface { + + /** + * The decorated executable finder. + * + * @var \PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder + */ + private $decorated; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + private $configFactory; + + /** + * Constructs an ExecutableFinder object. + * + * @param \Symfony\Component\Process\ExecutableFinder $symfony_executable_finder + * The Symfony executable finder. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + */ + public function __construct(SymfonyExecutableFinder $symfony_executable_finder, ConfigFactoryInterface $config_factory) { + $this->decorated = new StagerExecutableFinder($symfony_executable_finder); + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public function find(string $name): string { + $executables = $this->configFactory->get('package_manager.settings') + ->get('executables'); + + return $executables[$name] ?? $this->decorated->find($name); + } + +} diff --git a/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php b/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..727620a24857d3f7a1ab7e900b3d6b675393844f --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php @@ -0,0 +1,82 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface; +use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory; +use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface; +use Symfony\Component\Process\ExecutableFinder; + +/** + * A file syncer factory which returns file syncers according to configuration. + */ +class FileSyncerFactory implements FileSyncerFactoryInterface { + + /** + * The decorated file syncer factory. + * + * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface + */ + protected $decorated; + + /** + * The PHP file syncer service. + * + * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + */ + protected $phpFileSyncer; + + /** + * The rsync file syncer service. + * + * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + */ + protected $rsyncFileSyncer; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * Constructs a FileCopierFactory object. + * + * @param \Symfony\Component\Process\ExecutableFinder $executable_finder + * The Symfony executable finder. + * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $php_file_syncer + * The PHP file syncer service. + * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $rsync_file_syncer + * The rsync file syncer service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + */ + public function __construct(ExecutableFinder $executable_finder, FileSyncerInterface $php_file_syncer, FileSyncerInterface $rsync_file_syncer, ConfigFactoryInterface $config_factory) { + $this->decorated = new StagerFileSyncerFactory($executable_finder, $php_file_syncer, $rsync_file_syncer); + $this->phpFileSyncer = $php_file_syncer; + $this->rsyncFileSyncer = $rsync_file_syncer; + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public function create(): FileSyncerInterface { + $syncer = $this->configFactory->get('package_manager.settings') + ->get('file_syncer'); + + switch ($syncer) { + case 'rsync': + return $this->rsyncFileSyncer; + + case 'php': + return $this->phpFileSyncer; + + default: + return $this->decorated->create(); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php b/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..3d74d54d355c2bd60c9115d47a3af4a8805167dc --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php @@ -0,0 +1,31 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\package_manager\EventSubscriber\UpdateDataSubscriber; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Defines dynamic container services for Package Manager. + */ +class PackageManagerServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + if (array_key_exists('update', $container->getParameter('container.modules'))) { + $container->register('package_manager.update_data_subscriber') + ->setClass(UpdateDataSubscriber::class) + ->setArguments([ + new Reference('update.manager'), + ]) + ->addTag('event_subscriber'); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/src/PathLocator.php b/core/modules/auto_updates/package_manager/src/PathLocator.php new file mode 100644 index 0000000000000000000000000000000000000000..8e14b31156c05ceed2d2ad26bb7213fc3540364d --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/PathLocator.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\package_manager; + +use Composer\Autoload\ClassLoader; + +/** + * Computes file system paths that are needed to stage code changes. + */ +class PathLocator { + + /** + * The absolute path of the running Drupal code base. + * + * @var string + */ + protected $appRoot; + + /** + * Constructs a PathLocator object. + * + * @param string $app_root + * The absolute path of the running Drupal code base. + */ + public function __construct(string $app_root) { + $this->appRoot = $app_root; + } + + /** + * Returns the path of the active code base. + * + * @return string + * The absolute path of the active, running code base. + */ + public function getActiveDirectory(): string { + return $this->getProjectRoot(); + } + + /** + * Returns the absolute path of the project root. + * + * This is where the project-level composer.json should normally be found, and + * may or may not be the same path as the Drupal code base. + * + * @return string + * The absolute path of the project root. + */ + public function getProjectRoot(): string { + // Assume that the vendor directory is immediately below the project root. + return realpath($this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..'); + } + + /** + * Returns the absolute path of the vendor directory. + * + * @return string + * The absolute path of the vendor directory. + */ + public function getVendorDirectory(): string { + $reflector = new \ReflectionClass(ClassLoader::class); + return dirname($reflector->getFileName(), 2); + } + + /** + * Returns the path of the Drupal installation, relative to the project root. + * + * @return string + * The path of the Drupal installation, relative to the project root and + * without leading or trailing slashes. Will return an empty string if the + * project root and Drupal root are the same. + */ + public function getWebRoot(): string { + $web_root = str_replace($this->getProjectRoot(), NULL, $this->appRoot); + return trim($web_root, DIRECTORY_SEPARATOR); + } + +} diff --git a/core/modules/auto_updates/package_manager/src/ProcessFactory.php b/core/modules/auto_updates/package_manager/src/ProcessFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..8a1ca6583a3be2350d2ba5e53162e758ed512df8 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/ProcessFactory.php @@ -0,0 +1,115 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; +use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactory as StagerProcessFactory; +use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface; +use Symfony\Component\Process\Process; + +/** + * Defines a process factory which sets the COMPOSER_HOME environment variable. + * + * @todo Figure out how to do this in composer_stager. + */ +final class ProcessFactory implements ProcessFactoryInterface { + + /** + * The decorated process factory. + * + * @var \PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface + */ + private $decorated; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + private $fileSystem; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + private $configFactory; + + /** + * Constructs a ProcessFactory object. + * + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + */ + public function __construct(FileSystemInterface $file_system, ConfigFactoryInterface $config_factory) { + $this->decorated = new StagerProcessFactory(); + $this->fileSystem = $file_system; + $this->configFactory = $config_factory; + } + + /** + * Returns the value of an environment variable. + * + * @param string $variable + * The name of the variable. + * + * @return mixed + * The value of the variable. + */ + private function getEnv(string $variable) { + if (function_exists('apache_getenv')) { + return apache_getenv($variable); + } + return getenv($variable); + } + + /** + * {@inheritdoc} + */ + public function create(array $command): Process { + $process = $this->decorated->create($command); + + $env = $process->getEnv(); + if ($this->isComposerCommand($command)) { + $env['COMPOSER_HOME'] = $this->getComposerHomePath(); + } + // Ensure that the running PHP binary is in the PATH. + $env['PATH'] = $this->getEnv('PATH') . ':' . dirname(PHP_BINARY); + return $process->setEnv($env); + } + + /** + * Returns the path to use as the COMPOSER_HOME environment variable. + * + * @return string + * The path which should be used as COMPOSER_HOME. + */ + private function getComposerHomePath(): string { + $home_path = $this->fileSystem->getTempDirectory(); + $home_path .= '/auto_updates_composer_home-'; + $home_path .= $this->configFactory->get('system.site')->get('uuid'); + $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + + return $home_path; + } + + /** + * Determines if a command is running Composer. + * + * @param string[] $command + * The command parts. + * + * @return bool + * TRUE if the command is running Composer, FALSE otherwise. + */ + private function isComposerCommand(array $command): bool { + $executable = $command[0]; + $executable_parts = explode('/', $executable); + $file = array_pop($executable_parts); + return strpos($file, 'composer') === 0; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/Stage.php b/core/modules/auto_updates/package_manager/src/Stage.php new file mode 100644 index 0000000000000000000000000000000000000000..6548db10cf65b6addb8f6a034aa38ec1273394fb --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/Stage.php @@ -0,0 +1,456 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\Crypt; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreDestroyEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager\Exception\StageValidationException; +use PhpTuf\ComposerStager\Domain\BeginnerInterface; +use PhpTuf\ComposerStager\Domain\CommitterInterface; +use PhpTuf\ComposerStager\Domain\StagerInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Creates and manages a staging area in which to install or update code. + * + * Allows calling code to copy the current Drupal site into a temporary staging + * directory, use Composer to require packages into it, sync changes from the + * staging directory back into the active code base, and then delete the + * staging directory. + * + * Only one staging area can exist at any given time, and the stage is owned by + * the user or session that originally created it. Only the owner can perform + * operations on the 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. + */ +class Stage { + + /** + * The tempstore key under which to store the locking info for this stage. + * + * @var string + */ + protected const TEMPSTORE_LOCK_KEY = 'lock'; + + /** + * The tempstore key under which to store arbitrary metadata for this stage. + * + * @var string + */ + protected const TEMPSTORE_METADATA_KEY = 'metadata'; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * The beginner service from Composer Stager. + * + * @var \PhpTuf\ComposerStager\Domain\BeginnerInterface + */ + protected $beginner; + + /** + * The stager service from Composer Stager. + * + * @var \PhpTuf\ComposerStager\Domain\StagerInterface + */ + protected $stager; + + /** + * The committer service from Composer Stager. + * + * @var \PhpTuf\ComposerStager\Domain\CommitterInterface + */ + protected $committer; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + protected $fileSystem; + + /** + * The event dispatcher service. + * + * @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * The shared temp store. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected $tempStore; + + /** + * The lock info for the stage. + * + * Consists of a unique random string and the current class name. + * + * @var string[] + */ + private $lock; + + /** + * Constructs a new Stage object. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner + * The beginner service from Composer Stager. + * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager + * The stager service from Composer Stager. + * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer + * The committer service from Composer Stager. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher + * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore + * The shared tempstore factory. + */ + public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) { + $this->pathLocator = $path_locator; + $this->beginner = $beginner; + $this->stager = $stager; + $this->committer = $committer; + $this->fileSystem = $file_system; + $this->eventDispatcher = $event_dispatcher; + $this->tempStore = $shared_tempstore->get('package_manager_stage'); + } + + /** + * Determines if the staging area can be created. + * + * @return bool + * TRUE if the staging area can be created, otherwise FALSE. + */ + final public function isAvailable(): bool { + return empty($this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY)); + } + + /** + * Returns a specific piece of metadata associated with this stage. + * + * Only the owner of the stage can access metadata, and the stage must either + * be claimed by its owner, or created during the current request. + * + * @param string $key + * The metadata key. + * + * @return mixed + * The metadata value, or NULL if it is not set. + */ + protected function getMetadata(string $key) { + $this->checkOwnership(); + + $metadata = $this->tempStore->getIfOwner(static::TEMPSTORE_METADATA_KEY) ?: []; + return $metadata[$key] ?? NULL; + } + + /** + * Stores arbitrary metadata associated with this stage. + * + * Only the owner of the stage can set metadata, and the stage must either be + * claimed by its owner, or created during the current request. + * + * @param string $key + * The key under which to store the metadata. + * @param mixed $data + * The metadata to store. + */ + protected function setMetadata(string $key, $data): void { + $this->checkOwnership(); + + $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY); + $metadata[$key] = $data; + $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, $metadata); + } + + /** + * Copies the active code base into the staging area. + * + * This will automatically claim the stage, so external code does NOT need to + * call ::claim(). However, if it was created during another request, the + * stage must be claimed before operations can be performed on it. + * + * @return string + * Unique ID for the stage, which can be used to claim the stage before + * performing other operations on it. Calling code should store this ID for + * as long as the stage needs to exist. + * + * @see ::claim() + */ + public function create(): string { + if (!$this->isAvailable()) { + throw new StageException('Cannot create a new stage because one already exists.'); + } + // Mark the stage as unavailable as early as possible, before dispatching + // the pre-create event. The idea is to prevent a race condition if the + // event subscribers take a while to finish, and two different users attempt + // to create a staging area at around the same time. If an error occurs + // while the event is being processed, the stage is marked as available. + // @see ::dispatch() + $id = Crypt::randomBytesBase64(); + $this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [$id, static::class]); + $this->claim($id); + + $active_dir = $this->pathLocator->getActiveDirectory(); + $stage_dir = $this->getStageDirectory(); + + $event = new PreCreateEvent($this); + $this->dispatch($event); + + $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths()); + $this->dispatch(new PostCreateEvent($this)); + return $id; + } + + /** + * Requires packages in the staging area. + * + * @param string[] $constraints + * The packages to require, in the form 'vendor/name:version'. + * @param bool $dev + * (optional) Whether the packages should be required as dev dependencies. + * Defaults to FALSE. + */ + public function require(array $constraints, bool $dev = FALSE): void { + $this->checkOwnership(); + + $command = array_merge(['require'], $constraints); + $command[] = '--update-with-all-dependencies'; + if ($dev) { + $command[] = '--dev'; + } + + $this->dispatch(new PreRequireEvent($this)); + $this->stager->stage($command, $this->getStageDirectory()); + $this->dispatch(new PostRequireEvent($this)); + } + + /** + * Applies staged changes to the active directory. + */ + public function apply(): void { + $this->checkOwnership(); + + $active_dir = $this->pathLocator->getActiveDirectory(); + $stage_dir = $this->getStageDirectory(); + + $event = new PreApplyEvent($this); + $this->dispatch($event); + + $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths()); + $this->dispatch(new PostApplyEvent($this)); + } + + /** + * Deletes the staging area. + * + * @param bool $force + * (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. + */ + public function destroy(bool $force = FALSE): void { + if (!$force) { + $this->checkOwnership(); + } + + $this->dispatch(new PreDestroyEvent($this)); + // Delete all directories in parent staging directory. + $parent_stage_dir = static::getStagingRoot(); + if (is_dir($parent_stage_dir)) { + $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void { + $this->fileSystem->chmod($path, 0777); + }); + } + $this->markAsAvailable(); + $this->dispatch(new PostDestroyEvent($this)); + } + + /** + * Marks the stage as available. + */ + protected function markAsAvailable(): void { + $this->tempStore->delete(static::TEMPSTORE_METADATA_KEY); + $this->tempStore->delete(static::TEMPSTORE_LOCK_KEY); + $this->lock = NULL; + } + + /** + * Dispatches an event and handles any errors that it collects. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + * + * @throws \Drupal\package_manager\Exception\StageValidationException + * If the event collects any validation errors, or a subscriber throws a + * StageValidationException directly. + * @throws \RuntimeException + * If any other sort of error occurs. + */ + protected function dispatch(StageEvent $event): void { + try { + $this->eventDispatcher->dispatch($event); + + $results = $event->getResults(); + if ($results) { + throw new StageValidationException($results); + } + } + catch (\Throwable $error) { + // If we are not going to be able to create the staging area, mark it as + // available. + // @see ::create() + if ($event instanceof PreCreateEvent) { + $this->markAsAvailable(); + } + + // Wrap the exception to preserve the backtrace, and re-throw it. + if ($error instanceof StageValidationException) { + throw new StageValidationException($error->getResults(), $error->getMessage(), $error->getCode(), $error); + } + else { + throw new \RuntimeException($error->getMessage(), $error->getCode(), $error); + } + } + } + + /** + * Returns a Composer utility object for the active directory. + * + * @return \Drupal\package_manager\ComposerUtility + * The Composer utility object. + */ + public function getActiveComposer(): ComposerUtility { + $dir = $this->pathLocator->getActiveDirectory(); + return ComposerUtility::createForDirectory($dir); + } + + /** + * Returns a Composer utility object for the stage directory. + * + * @return \Drupal\package_manager\ComposerUtility + * The Composer utility object. + */ + public function getStageComposer(): ComposerUtility { + $dir = $this->getStageDirectory(); + return ComposerUtility::createForDirectory($dir); + } + + /** + * Attempts to claim the stage. + * + * Once a stage has been created, no operations can be performed on it until + * it is claimed. This is to ensure that stage operations across multiple + * requests are being done by the same code, running under the same user or + * session that created the stage in the first place. To claim a stage, the + * calling code must provide the unique identifier that was generated when the + * stage was created. + * + * The stage is claimed when it is created, so external code does NOT need to + * call this method after calling ::create() in the same request. + * + * @param string $unique_id + * The unique ID that was returned by ::create(). + * + * @return $this + * + * @throws \Drupal\package_manager\Exception\StageException + * If the stage cannot be claimed. This can happen if the current user or + * session did not originally create the stage, if $unique_id doesn't match + * the unique ID that was generated when the stage was created, or the + * current class is not the same one that was used to create the stage. + * + * @see ::create() + */ + final public function claim(string $unique_id): self { + if ($this->isAvailable()) { + throw new StageException('Cannot claim the stage because no stage has been created.'); + } + + $stored_lock = $this->tempStore->getIfOwner(self::TEMPSTORE_LOCK_KEY); + if (!$stored_lock) { + throw new StageOwnershipException('Cannot claim the stage because it is not owned by the current user or session.'); + } + + if ($stored_lock === [$unique_id, static::class]) { + $this->lock = $stored_lock; + return $this; + } + throw new StageOwnershipException('Cannot claim the stage because the current lock does not match the stored lock.'); + } + + /** + * Ensures that the current user or session owns the staging area. + * + * @throws \LogicException + * If ::claim() has not been previously called. + * @throws \Drupal\package_manager\Exception\StageOwnershipException + * If the current user or session does not own the staging area. + */ + final protected function checkOwnership(): void { + if (empty($this->lock)) { + throw new \LogicException('Stage must be claimed before performing any operations on it.'); + } + + $stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY); + if ($stored_lock !== $this->lock) { + throw new StageOwnershipException('Stage is not owned by the current user or session.'); + } + } + + /** + * Returns the path of the directory where changes should be staged. + * + * @return string + * The absolute path of the directory where changes should be staged. + * + * @throws \LogicException + * If this method is called before the stage has been created or claimed. + * + * @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]; + } + + /** + * Returns the directory where staging areas will be created. + * + * @return 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'; + } + +} diff --git a/core/modules/auto_updates/package_manager/src/ValidationResult.php b/core/modules/auto_updates/package_manager/src/ValidationResult.php new file mode 100644 index 0000000000000000000000000000000000000000..7651548eddad405436595452917caaa1e1203d27 --- /dev/null +++ b/core/modules/auto_updates/package_manager/src/ValidationResult.php @@ -0,0 +1,113 @@ +<?php + +namespace Drupal\package_manager; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; + +/** + * A value object to contain the results of a validation. + */ +class ValidationResult { + + /** + * A succinct summary of the results. + * + * @var \Drupal\Core\StringTranslation\TranslatableMarkup + */ + protected $summary; + + /** + * The error messages. + * + * @var \Drupal\Core\StringTranslation\TranslatableMarkup[] + */ + protected $messages; + + /** + * The severity of the result. + * + * @var int + */ + protected $severity; + + /** + * Creates a ValidationResult object. + * + * @param int $severity + * The severity of the result. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + */ + private function __construct(int $severity, array $messages, ?TranslatableMarkup $summary = NULL) { + if (count($messages) > 1 && !$summary) { + throw new \InvalidArgumentException('If more than one message is provided, a summary is required.'); + } + $this->summary = $summary; + $this->messages = $messages; + $this->severity = $severity; + } + + /** + * Creates an error ValidationResult object. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + * + * @return static + */ + public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): self { + return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary); + } + + /** + * Creates a warning ValidationResult object. + * + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages + * The error messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The errors summary. + * + * @return static + */ + public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): self { + return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary); + } + + /** + * Gets the summary. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null + * The summary. + */ + public function getSummary(): ?TranslatableMarkup { + return $this->summary; + } + + /** + * Gets the messages. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup[] + * The error or warning messages. + */ + public function getMessages(): array { + return $this->messages; + } + + /** + * The severity of the result. + * + * @return int + * Either SystemManager::REQUIREMENT_ERROR or + * SystemManager::REQUIREMENT_WARNING. + */ + public function getSeverity(): int { + return $this->severity; + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php b/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php new file mode 100644 index 0000000000000000000000000000000000000000..7adacfb2e2e8d8bcc498735e7472f6fde2577340 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php @@ -0,0 +1,14 @@ +<?php + +/** + * @file + * Contains a fake database update function for testing. + */ + +/** + * Here is a fake update hook. + * + * The schema version is the maximum possible value for a 32-bit integer. + */ +function package_manager_update_2147483647() { +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..a7a8274cb6e5e850fba380e6670fbeb8b33a7cbd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json @@ -0,0 +1,12 @@ +{ + "require": { + "drupal/test-distribution": "*" + }, + "extra": { + "_comment": [ + "This is a fake composer.json simulating a site which requires a distribution.", + "The required core packages are determined by scanning the lock file.", + "The fake distribution requires Drupal core directly." + ] + } +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..56572fd029d8f859be438b6fa40b76ebbc9daf4c --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock @@ -0,0 +1,16 @@ +{ + "packages": [ + { + "name": "drupal/test-distribution", + "version": "1.0.0", + "require": { + "drupal/core": "*" + } + }, + { + "name": "drupal/core", + "version": "9.8.0" + } + ], + "packages-dev": [] +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..3a62074ca2371426bbf177a0b941f808fbbfae3a --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json @@ -0,0 +1,12 @@ +{ + "require": { + "drupal/test-distribution": "*" + }, + "extra": { + "_comment": [ + "This is a fake composer.json simulating a site which requires a distribution.", + "The required core packages are determined by scanning the lock file.", + "The fake distribution uses drupal/core-recommended to require Drupal core." + ] + } +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..dd29a0514d0ebdfc87484b52fc0c3256045ccb85 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock @@ -0,0 +1,23 @@ +{ + "packages": [ + { + "name": "drupal/test-distribution", + "version": "1.0.0", + "require": { + "drupal/core-recommended": "*" + } + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "require": { + "drupal/core": "9.8.0" + } + }, + { + "name": "drupal/core", + "version": "9.8.0" + } + ], + "packages-dev": [] +} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml new file mode 100644 index 0000000000000000000000000000000000000000..e11552b41d40377475700ab10cd3118257d93cc7 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php new file mode 100644 index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php new file mode 100644 index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt new file mode 100644 index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt @@ -0,0 +1 @@ +This file should be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml new file mode 100644 index 0000000000000000000000000000000000000000..e11552b41d40377475700ab10cd3118257d93cc7 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php new file mode 100644 index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php new file mode 100644 index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config new file mode 100644 index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..af1b5aac92f63110e9559a6bd0ce54b045d091ae --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Bypass' +description: 'Mocks Package Manager services for functional testing.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php new file mode 100644 index 0000000000000000000000000000000000000000..742d38bd85dd15311fd7cf9125e55aea1adcb905 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\package_manager_bypass; + +use PhpTuf\ComposerStager\Domain\BeginnerInterface; +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; + +/** + * Defines an update beginner which doesn't do anything. + */ +class Beginner extends InvocationRecorderBase implements BeginnerInterface { + + /** + * {@inheritdoc} + */ + public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php new file mode 100644 index 0000000000000000000000000000000000000000..3518237b5b1ba237b1d0b02a454bf0dfe9cd69ad --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php @@ -0,0 +1,44 @@ +<?php + +namespace Drupal\package_manager_bypass; + +use PhpTuf\ComposerStager\Domain\CommitterInterface; +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; + +/** + * Defines an update committer which doesn't do any actual committing. + */ +class Committer extends InvocationRecorderBase implements CommitterInterface { + + /** + * The decorated committer service. + * + * @var \PhpTuf\ComposerStager\Domain\CommitterInterface + */ + private $decorated; + + /** + * Constructs a Committer object. + * + * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $decorated + * The decorated committer service. + */ + public function __construct(CommitterInterface $decorated) { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions); + } + + /** + * {@inheritdoc} + */ + public function directoryExists(string $stagingDir): bool { + return $this->decorated->directoryExists($stagingDir); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php new file mode 100644 index 0000000000000000000000000000000000000000..feb4dc92f68825f6dc63f76cea8283269efcdc2f --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php @@ -0,0 +1,36 @@ +<?php + +namespace Drupal\package_manager_bypass; + +/** + * Records information about method invocations. + * + * This can be used by functional tests to ensure that the bypassed Composer + * Stager services were called as expected. Kernel and unit tests should use + * regular mocks instead. + */ +abstract class InvocationRecorderBase { + + /** + * Returns the arguments from every invocation of the main class method. + * + * @return array[] + * The arguments from every invocation of the main class method. + */ + public function getInvocationArguments(): array { + return \Drupal::state()->get(static::class, []); + } + + /** + * Records the arguments from an invocation of the main class method. + * + * @param mixed ...$arguments + * The arguments that the main class method was called with. + */ + protected function saveInvocationArguments(...$arguments) { + $invocations = $this->getInvocationArguments(); + $invocations[] = $arguments; + \Drupal::state()->set(static::class, $invocations); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..3394cf4e159b39d300273529e119dc94929346c3 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php @@ -0,0 +1,47 @@ +<?php + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\Core\Site\Settings; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Defines services to bypass Package Manager's core functionality. + */ +class PackageManagerBypassServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container) { + parent::alter($container); + + // By default, bypass the Composer Stager library. This can be disabled for + // tests that want to use the real library, but only need to disable + // validators. + if (Settings::get('package_manager_bypass_stager', TRUE)) { + $container->getDefinition('package_manager.beginner') + ->setClass(Beginner::class); + $container->getDefinition('package_manager.stager') + ->setClass(Stager::class); + + $container->register('package_manager_bypass.committer') + ->setClass(Committer::class) + ->setPublic(FALSE) + ->setDecoratedService('package_manager.committer') + ->setArguments([ + new Reference('package_manager_bypass.committer.inner'), + ]) + ->setProperty('_serviceId', 'package_manager.committer'); + } + + // Allow functional tests to disable specific validators as necessary. + // Kernel tests can override the ::register() method and modify the + // container directly. + $validators = Settings::get('package_manager_bypass_validators', []); + array_walk($validators, [$container, 'removeDefinition']); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php new file mode 100644 index 0000000000000000000000000000000000000000..bb93e47271dbe60e725ff33e574065d01e503715 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\package_manager_bypass; + +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\StagerInterface; + +/** + * Defines an update stager which doesn't actually do anything. + */ +class Stager extends InvocationRecorderBase implements StagerInterface { + + /** + * {@inheritdoc} + */ + public function stage(array $composerCommand, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + $this->saveInvocationArguments($composerCommand, $stagingDir); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..3558b4cd3c8872140eac8bcc224ea004d61d4708 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Validation Test' +description: 'Provides an event subscriber to test Package Manager validation.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..6cd0f446bf3932acd5e024006f8f8e4159ce6b07 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml @@ -0,0 +1,7 @@ +services: + package_manager_test_validation.subscriber: + class: Drupal\package_manager_test_validation\TestSubscriber + arguments: + - '@state' + tags: + - { name: event_subscriber } diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..a95381d3e80da1bc3ad0d87488143f46251e3538 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php @@ -0,0 +1,134 @@ +<?php + +namespace Drupal\package_manager_test_validation; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreDestroyEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\system\SystemManager; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber for testing validation of Package Manager events. + */ +class TestSubscriber implements EventSubscriberInterface { + + /** + * The key to use store the test results. + * + * @var string + */ + protected const STATE_KEY = 'package_manager_test_validation'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * Creates a TestSubscriber object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(StateInterface $state) { + $this->state = $state; + } + + /** + * Sets validation results for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Drupal\package_manager\ValidationResult[]|null $results + * The validation results, or NULL to delete stored results. + * @param string $event + * The event class. + */ + public static function setTestResult(?array $results, string $event): void { + $key = static::STATE_KEY . '.' . $event; + + $state = \Drupal::state(); + if (isset($results)) { + $state->set($key, $results); + } + else { + $state->delete($key); + } + } + + /** + * Sets an exception to throw for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Throwable|null $error + * The exception to throw, or NULL to delete a stored exception. + * @param string $event + * The event class. + */ + public static function setException(?\Throwable $error, string $event): void { + $key = static::STATE_KEY . '.' . $event; + + $state = \Drupal::state(); + if (isset($error)) { + $state->set($key, $error); + } + else { + $state->delete($key); + } + } + + /** + * Adds validation results to a stage event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function addResults(StageEvent $event): void { + $results = $this->state->get(static::STATE_KEY . '.' . get_class($event), []); + + if ($results instanceof \Throwable) { + throw $results; + } + /** @var \Drupal\package_manager\ValidationResult $result */ + foreach ($results as $result) { + if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) { + $event->addError($result->getMessages(), $result->getSummary()); + } + else { + $event->addWarning($result->getMessages(), $result->getSummary()); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $priority = defined('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY') ? PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY : 5; + + return [ + PreCreateEvent::class => ['addResults', $priority], + PostCreateEvent::class => ['addResults', $priority], + PreRequireEvent::class => ['addResults', $priority], + PostRequireEvent::class => ['addResults', $priority], + PreApplyEvent::class => ['addResults', $priority], + PostApplyEvent::class => ['addResults', $priority], + PreDestroyEvent::class => ['addResults', $priority], + PostDestroyEvent::class => ['addResults', $priority], + ]; + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php b/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..94131a1ca3adfb438f0897bf7784c63d3d502e8f --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php @@ -0,0 +1,155 @@ +<?php + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\Core\Database\Driver\sqlite\Connection; +use Drupal\Core\Site\Settings; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\Stage; +use Drupal\Tests\BrowserTestBase; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber + * + * @group package_manager + */ +class ExcludedPathsTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected function prepareSettings() { + parent::prepareSettings(); + + // Disable the filesystem permissions validator, since we cannot guarantee + // that the current code base will be writable in all testing situations. + // We test this validator functionally in Automatic Updates' build tests, + // since those do give us control over the filesystem permissions. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest + $this->writeSettings([ + 'settings' => [ + 'package_manager_bypass_stager' => (object) [ + 'value' => FALSE, + 'required' => TRUE, + ], + 'package_manager_bypass_validators' => (object) [ + 'value' => ['package_manager.validator.file_system'], + 'required' => TRUE, + ], + ], + ]); + } + + /** + * Tests that certain paths are excluded from staging areas. + */ + public function testExcludedPaths(): void { + $active_dir = __DIR__ . '/../../fixtures/fake_site'; + + $path_locator = $this->prophesize(PathLocator::class); + $path_locator->getActiveDirectory()->willReturn($active_dir); + + $site_path = 'sites/example.com'; + + // Ensure that we are using directories within the fake site fixture for + // public and private files. + $settings = Settings::getAll(); + $settings['file_public_path'] = "$site_path/files"; + $settings['file_private_path'] = 'private'; + new Settings($settings); + + /** @var \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber $subscriber */ + $subscriber = $this->container->get('package_manager.excluded_paths_subscriber'); + $reflector = new \ReflectionObject($subscriber); + $property = $reflector->getProperty('sitePath'); + $property->setAccessible(TRUE); + $property->setValue($subscriber, $site_path); + + // Mock a SQLite database connection to a file in the active directory. The + // file should not be staged. + $database = $this->prophesize(Connection::class); + $database->driver()->willReturn('sqlite'); + $database->getConnectionOptions()->willReturn([ + 'database' => $site_path . '/db.sqlite', + ]); + $property = $reflector->getProperty('database'); + $property->setAccessible(TRUE); + $property->setValue($subscriber, $database->reveal()); + + $stage = new class( + $path_locator->reveal(), + $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'), + ) extends Stage { + + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + protected static function getStagingRoot(): string { + return static::$stagingRoot; + } + + }; + $stage::$stagingRoot = $this->siteDirectory . '/stage'; + $stage_dir = $stage::$stagingRoot . DIRECTORY_SEPARATOR . $stage->create(); + + $this->assertDirectoryExists($stage_dir); + $this->assertDirectoryNotExists("$stage_dir/sites/simpletest"); + $this->assertFileNotExists("$stage_dir/vendor/web.config"); + $this->assertDirectoryNotExists("$stage_dir/$site_path/files"); + $this->assertDirectoryNotExists("$stage_dir/private"); + $this->assertFileNotExists("$stage_dir/$site_path/settings.php"); + $this->assertFileNotExists("$stage_dir/$site_path/settings.local.php"); + $this->assertFileNotExists("$stage_dir/$site_path/services.yml"); + // SQLite databases and their support files should never be staged. + $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite"); + $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite-shm"); + $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite-wal"); + // Default site-specific settings files should never be staged. + $this->assertFileNotExists("$stage_dir/sites/default/settings.php"); + $this->assertFileNotExists("$stage_dir/sites/default/settings.local.php"); + $this->assertFileNotExists("$stage_dir/sites/default/services.yml"); + // A non-excluded file in the default site directory should be staged. + $this->assertFileExists("$stage_dir/sites/default/stage.txt"); + + $files = [ + 'sites/default/no-copy.txt', + 'web.config', + ]; + foreach ($files as $file) { + $file = "$stage_dir/$file"; + touch($file); + $this->assertFileExists($file); + } + $stage->apply(); + foreach ($files as $file) { + $this->assertFileNotExists("$active_dir/$file"); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8905f788eddac081411dcadc5088121e7658ff96 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php @@ -0,0 +1,137 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\EventSubscriber\ComposerExecutableValidator; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\Exception\IOException; +use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface; +use Prophecy\Argument; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator + * + * @group package_manager + */ +class ComposerExecutableValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests that an error is raised if the Composer executable isn't found. + */ + public function testErrorIfComposerNotFound(): void { + $exception = new IOException("This is your regularly scheduled error."); + + // The executable finder throws an exception if it can't find the requested + // executable. + $exec_finder = $this->prophesize(ExecutableFinderInterface::class); + $exec_finder->find('composer') + ->willThrow($exception) + ->shouldBeCalled(); + $this->container->set('package_manager.executable_finder', $exec_finder->reveal()); + + // The validator should translate that exception into an error. + $error = ValidationResult::createError([ + $exception->getMessage(), + ]); + $this->assertResults([$error], PreCreateEvent::class); + } + + /** + * Data provider for ::testComposerVersionValidation(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerComposerVersionValidation(): array { + // Invalid or undetectable Composer versions will always produce the same + // error. + $invalid_version = ValidationResult::createError(['The Composer version could not be detected.']); + + // Unsupported Composer versions will report the detected version number + // in the validation result, so we need a function to churn out those fake + // results for the test method. + $unsupported_version = function (string $version): ValidationResult { + return ValidationResult::createError([ + "Composer 2 or later is required, but version $version was detected.", + ]); + }; + + return [ + // A valid 2.x version of Composer should not produce any errors. + [ + '2.1.6', + [], + ], + [ + '1.10.22', + [$unsupported_version('1.10.22')], + ], + [ + '1.7.3', + [$unsupported_version('1.7.3')], + ], + [ + '2.0.0-alpha3', + [], + ], + [ + '2.1.0-RC1', + [], + ], + [ + '1.0.0-RC', + [$unsupported_version('1.0.0-RC')], + ], + [ + '1.0.0-beta1', + [$unsupported_version('1.0.0-beta1')], + ], + [ + '1.9-dev', + [$invalid_version], + ], + [ + '@package_version@', + [$invalid_version], + ], + ]; + } + + /** + * Tests validation of various Composer versions. + * + * @param string $reported_version + * The version of Composer that `composer --version` should report. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerComposerVersionValidation + */ + public function testComposerVersionValidation(string $reported_version, array $expected_results): void { + // Mock the output of `composer --version`, will be passed to the validator, + // which is itself a callback function that gets called repeatedly as + // Composer produces output. + /** @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */ + $runner = $this->prophesize('\PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface'); + + $runner->run(['--version'], Argument::type(ComposerExecutableValidator::class)) + // Whatever is passed to ::run() will be passed to this mock callback in + // $arguments, and we know exactly what that will contain: an array of + // command arguments for Composer, and the validator object. + ->will(function (array $arguments) use ($reported_version) { + /** @var \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator $validator */ + $validator = $arguments[1]; + // Invoke the validator (which, as mentioned, is a callback function), + // with fake output from `composer --version`. It should try to tease a + // recognized, supported version number out of this output. + $validator($validator::OUT, "Composer version $reported_version"); + }); + $this->container->set('package_manager.composer_runner', $runner->reveal()); + + // If the validator can't find a recognized, supported version of Composer, + // it should produce errors. + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d770cd3a12835108d85b667935c9b59b384cf4d1 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php @@ -0,0 +1,75 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\ComposerUtility; +use org\bovigo\vfs\vfsStream; + +/** + * @coversDefaultClass \Drupal\package_manager\ComposerUtility + * + * @group package_manager + */ +class ComposerUtilityTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager']; + + /** + * Tests that ComposerUtility disables automatic creation of .htaccess files. + */ + public function testHtaccessProtectionDisabled(): void { + $dir = vfsStream::setup()->url(); + file_put_contents($dir . '/composer.json', '{}'); + + ComposerUtility::createForDirectory($dir); + $this->assertFileNotExists($dir . '/.htaccess'); + } + + /** + * Data provider for ::testCorePackagesFromLockFile(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerCorePackagesFromLockFile(): array { + $fixtures_dir = __DIR__ . '/../../fixtures'; + + return [ + 'distro with drupal/core-recommended' => [ + // This fixture's lock file mentions drupal/core, which is considered a + // canonical core package, but it will be ignored in favor of + // drupal/core-recommended, which always requires drupal/core as one of + // its direct dependencies. + "$fixtures_dir/distro_core_recommended", + ['drupal/core-recommended'], + ], + 'distro with drupal/core' => [ + "$fixtures_dir/distro_core", + ['drupal/core'], + ], + ]; + } + + /** + * Tests that required core packages are found by scanning the lock file. + * + * @param string $dir + * The path of the fake site fixture. + * @param string[] $expected_packages + * The names of the core packages which should be detected. + * + * @covers ::getCorePackageNames + * + * @dataProvider providerCorePackagesFromLockFile + */ + public function testCorePackagesFromLockFile(string $dir, array $expected_packages): void { + $packages = ComposerUtility::createForDirectory($dir) + ->getCorePackageNames(); + $this->assertSame($expected_packages, $packages); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5d82264e8afc36c6770e1d808e99852ac7869376 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Serialization\Json; +use Drupal\KernelTests\KernelTestBase; +use Symfony\Component\Finder\Finder; + +/** + * Tests that core's Composer packages are properly accounted for. + * + * In order to identify which Composer packages are part of Drupal core, we need + * to maintain a single hard-coded list (core_packages.json). This test confirms + * that the list mentions all of the Composer plugins and metapackages provided + * by Drupal core. + * + * @todo Move this test, and the package list, to a more central place in core. + * For example, the list could live in core/assets, and this test could live + * in the Drupal\Tests\Composer namespace. + * + * @group package_manager + */ +class CorePackageManifestTest extends KernelTestBase { + + /** + * Tests that detected core packages match our hard-coded manifest file. + */ + public function testCorePackagesMatchManifest(): void { + // Scan for all the composer.json files of said metapackages and plugins, + // ignoring the project templates. If we are not running in git clone of + // Drupal core, this will fail since the 'composer' directory won't exist. + $finder = Finder::create() + ->in($this->getDrupalRoot() . '/composer') + ->name('composer.json') + ->notPath('Template'); + + // Always consider drupal/core a valid core package, even though it's not a + // metapackage or plugin. + $packages = ['drupal/core']; + foreach ($finder as $file) { + $data = Json::decode($file->getContents()); + $packages[] = $data['name']; + } + sort($packages); + + // Ensure that the packages we detected matches the hard-coded list we ship. + $manifest = file_get_contents(__DIR__ . '/../../../core_packages.json'); + $manifest = Json::decode($manifest); + $this->assertSame($packages, $manifest); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c4ee293ee0cace75c07a900acd227c0994807fcf --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php @@ -0,0 +1,225 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\EventSubscriber\DiskSpaceValidator; +use Drupal\package_manager\ValidationResult; +use Drupal\Component\Utility\Bytes; + +/** + * @covers \Drupal\package_manager\EventSubscriber\DiskSpaceValidator + * + * @group package_manager + */ +class DiskSpaceValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // Replace the validator under test with a mocked version which can be + // rigged up to return specific values for various filesystem checks. + $container->getDefinition('package_manager.validator.disk_space') + ->setClass(TestDiskSpaceValidator::class); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // Disable the lock file validator, since in this test we are validating an + // imaginary file system which doesn't have any lock files. + $container->removeDefinition('package_manager.validator.lock_file'); + } + + /** + * Data provider for ::testDiskSpaceValidation(). + * + * @return mixed[][] + * Sets of arguments to pass to the test method. + */ + public function providerDiskSpaceValidation(): array { + $root_insufficient = t('Drupal root filesystem "root" has insufficient space. There must be at least 1024 megabytes free.'); + $vendor_insufficient = t('Vendor filesystem "vendor" has insufficient space. There must be at least 1024 megabytes free.'); + $temp_insufficient = t('Directory "temp" has insufficient space. There must be at least 1024 megabytes free.'); + $summary = t("There is not enough disk space to create a staging area."); + + return [ + 'shared, vendor and temp sufficient, root insufficient' => [ + TRUE, + [ + 'root' => '1M', + 'vendor' => '2G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, root and vendor insufficient, temp sufficient' => [ + TRUE, + [ + 'root' => '1M', + 'vendor' => '2M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, vendor and root sufficient, temp insufficient' => [ + TRUE, + [ + 'root' => '2G', + 'vendor' => '4G', + 'temp' => '1M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'shared, root and temp insufficient, vendor sufficient' => [ + TRUE, + [ + 'root' => '1M', + 'vendor' => '2G', + 'temp' => '2M', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $temp_insufficient, + ], $summary), + ], + ], + 'not shared, root insufficient, vendor and temp sufficient' => [ + FALSE, + [ + 'root' => '5M', + 'vendor' => '1G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'not shared, vendor insufficient, root and temp sufficient' => [ + FALSE, + [ + 'root' => '2G', + 'vendor' => '10M', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$vendor_insufficient]), + ], + ], + 'not shared, root and vendor sufficient, temp insufficient' => [ + FALSE, + [ + 'root' => '1G', + 'vendor' => '2G', + 'temp' => '3M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'not shared, root and vendor insufficient, temp sufficient' => [ + FALSE, + [ + 'root' => '500M', + 'vendor' => '75M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $vendor_insufficient, + ], $summary), + ], + ], + ]; + } + + /** + * Tests disk space validation. + * + * @param bool $shared_disk + * Whether the root and vendor directories are on the same logical disk. + * @param array $free_space + * The free space that should be reported for various locations. The keys + * are the locations (only 'root', 'vendor', and 'temp' are supported), and + * the values are the space that should be reported, in a format that can be + * parsed by \Drupal\Component\Utility\Bytes::toNumber(). + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerDiskSpaceValidation + */ + public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void { + $path_locator = $this->prophesize('\Drupal\package_manager\PathLocator'); + $path_locator->getProjectRoot()->willReturn('root'); + $path_locator->getActiveDirectory()->willReturn('root'); + $path_locator->getVendorDirectory()->willReturn('vendor'); + $this->container->set('package_manager.path_locator', $path_locator->reveal()); + + /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */ + $validator = $this->container->get('package_manager.validator.disk_space'); + $validator->sharedDisk = $shared_disk; + $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space); + + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} + +/** + * A test version of the disk space validator. + */ +class TestDiskSpaceValidator extends DiskSpaceValidator { + + /** + * Whether the root and vendor directories are on the same logical disk. + * + * @var bool + */ + public $sharedDisk; + + /** + * The amount of free space, keyed by location. + * + * @var float[] + */ + public $freeSpace = []; + + /** + * {@inheritdoc} + */ + protected function stat(string $path): array { + return [ + 'dev' => $this->sharedDisk ? 'disk' : uniqid(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function freeSpace(string $path): float { + return $this->freeSpace[$path]; + } + + /** + * {@inheritdoc} + */ + protected function temporaryDirectory(): string { + return 'temp'; + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b44f7811d62c0699d5a2786747b5c4cf51051efa --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php @@ -0,0 +1,99 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Database\Driver\sqlite\Connection; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber + * + * @group package_manager + */ +class ExcludedPathsSubscriberTest extends PackageManagerKernelTestBase { + + /** + * Data provider for ::testSqliteDatabaseExcluded(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerSqliteDatabaseExcluded(): array { + $drupal_root = $this->getDrupalRoot(); + + return [ + 'relative path, in site directory' => [ + 'sites/example.com/db.sqlite', + [ + 'sites/example.com/db.sqlite', + 'sites/example.com/db.sqlite-shm', + 'sites/example.com/db.sqlite-wal', + ], + ], + 'relative path, at root' => [ + 'db.sqlite', + [ + 'db.sqlite', + 'db.sqlite-shm', + 'db.sqlite-wal', + ], + ], + 'absolute path, in site directory' => [ + $drupal_root . '/sites/example.com/db.sqlite', + [ + 'sites/example.com/db.sqlite', + 'sites/example.com/db.sqlite-shm', + 'sites/example.com/db.sqlite-wal', + ], + ], + 'absolute path, at root' => [ + $drupal_root . '/db.sqlite', + [ + 'db.sqlite', + 'db.sqlite-shm', + 'db.sqlite-wal', + ], + ], + ]; + } + + /** + * Tests that SQLite database paths are excluded from the staging area. + * + * The exclusion of SQLite databases from the staging area is functionally + * tested by \Drupal\Tests\package_manager\Functional\ExcludedPathsTest. The + * purpose of this test is to ensure that SQLite database paths are processed + * properly (e.g., converting an absolute path to a relative path) before + * being flagged for exclusion. + * + * @param string $database + * The path of the SQLite database, as set in the database connection + * options. + * @param string[] $expected_exclusions + * The database paths which should be flagged for exclusion. + * + * @dataProvider providerSqliteDatabaseExcluded + * + * @see \Drupal\Tests\package_manager\Functional\ExcludedPathsTest + */ + public function testSqliteDatabaseExcluded(string $database, array $expected_exclusions): void { + $connection = $this->prophesize(Connection::class); + $connection->driver()->willReturn('sqlite'); + $connection->getConnectionOptions()->willReturn(['database' => $database]); + + $subscriber = new ExcludedPathsSubscriber( + $this->getDrupalRoot(), + 'sites/default', + $this->container->get('file_system'), + $this->container->get('stream_wrapper_manager'), + $connection->reveal() + ); + + $event = new PreCreateEvent($this->createStage()); + $subscriber->preCreate($event); + // All of the expected exclusions should be flagged. + $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths())); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fb59c652ca497251488702c1f90bb367c896d40b --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder; + +/** + * @covers \Drupal\package_manager\ExecutableFinder + * + * @group package_manager + */ +class ExecutableFinderTest extends PackageManagerKernelTestBase { + + /** + * Tests that the executable finder looks for paths in configuration. + */ + public function testCheckConfigurationForExecutablePath(): void { + $symfony_executable_finder = new class () extends SymfonyExecutableFinder { + + /** + * {@inheritdoc} + */ + public function find($name, $default = NULL, array $extraDirs = []) { + return '/dev/null'; + } + + }; + $this->container->set('package_manager.symfony_executable_finder', $symfony_executable_finder); + + $this->config('package_manager.settings') + ->set('executables.composer', '/path/to/composer') + ->save(); + + $executable_finder = $this->container->get('package_manager.executable_finder'); + $this->assertSame('/path/to/composer', $executable_finder->find('composer')); + $this->assertSame('/dev/null', $executable_finder->find('rsync')); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5609a64d4d7d861154b9521f1a5a8a3f91d2c012 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; + +/** + * @covers \Drupal\package_manager\FileSyncerFactory + * + * @group package_manager + */ +class FileSyncerFactoryTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager']; + + /** + * Data provider for ::testFactory(). + * + * @return mixed[][] + * Sets of arguments to pass to the test method. + */ + public function providerFactory(): array { + return [ + ['rsync'], + ['php'], + [NULL], + ]; + } + + /** + * Tests creating a file syncer using our specialized factory class. + * + * @param string|null $configured_syncer + * The syncer to use, as configured in auto_updates.settings. Can be + * 'rsync', 'php', or NULL. + * + * @dataProvider providerFactory + */ + public function testFactory(?string $configured_syncer): void { + $factory = $this->container->get('package_manager.file_syncer.factory'); + + switch ($configured_syncer) { + case 'rsync': + $expected_syncer = $this->container->get('package_manager.file_syncer.rsync'); + break; + + case 'php': + $expected_syncer = $this->container->get('package_manager.file_syncer.php'); + break; + + default: + $expected_syncer = $factory->create(); + break; + } + + $this->config('package_manager.settings') + ->set('file_syncer', $configured_syncer) + ->save(); + + $this->assertSame($expected_syncer, $factory->create()); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..62c0142462432c6237f26c06f5df49c11d16d543 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -0,0 +1,180 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\EventSubscriber\LockFileValidator; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use org\bovigo\vfs\vfsStream; + +/** + * @coversDefaultClass \Drupal\package_manager\EventSubscriber\LockFileValidator + * + * @group package_manager + */ +class LockFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $vendor = vfsStream::newDirectory('vendor'); + $this->vfsRoot->addChild($vendor); + + $path_locator = $this->prophesize(PathLocator::class); + $path_locator->getActiveDirectory()->willReturn($this->vfsRoot->url()); + $path_locator->getProjectRoot()->willReturn($this->vfsRoot->url()); + $path_locator->getWebRoot()->willReturn(''); + $path_locator->getVendorDirectory()->willReturn($vendor->url()); + $this->container->set('package_manager.path_locator', $path_locator->reveal()); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // Disable the disk space validator, since it tries to inspect the file + // system in ways that vfsStream doesn't support, like calling stat() and + // disk_free_space(). + $container->removeDefinition('package_manager.validator.disk_space'); + } + + /** + * Tests that if no active lock file exists, a stage cannot be created. + * + * @covers ::storeHash + */ + public function testCreateWithNoLock(): void { + $no_lock = ValidationResult::createError(['Could not hash the active lock file.']); + $this->assertResults([$no_lock], PreCreateEvent::class); + } + + /** + * Tests that if an active lock file exists, a stage can be created. + * + * @covers ::storeHash + * @covers ::deleteHash + */ + public function testCreateWithLock(): void { + $this->createActiveLockFile(); + $this->assertResults([]); + + // Change the lock file to ensure the stored hash of the previous version + // has been deleted. + $this->vfsRoot->getChild('composer.lock')->setContent('"changed"'); + $this->assertResults([]); + } + + /** + * Tests validation when the lock file has changed. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileChanged(string $event_class): void { + $this->createActiveLockFile(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener changes lock file before the validator + // runs. + $this->addListener($event_class, function () { + $this->vfsRoot->getChild('composer.lock')->setContent('"changed"'); + }); + $result = ValidationResult::createError([ + 'Stored lock file hash does not match the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Tests validation when the lock file is deleted. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileDeleted(string $event_class): void { + $this->createActiveLockFile(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes lock file before the validator + // runs. + $this->addListener($event_class, function () { + $this->vfsRoot->removeChild('composer.lock'); + }); + $result = ValidationResult::createError([ + 'Could not hash the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Tests validation when a stored hash of the active lock file is unavailable. + * + * @dataProvider providerValidateStageEvents + */ + public function testNoStoredHash(string $event_class): void { + $this->createActiveLockFile(); + + $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'STATE_KEY'); + $state_key = $reflector->getValue(); + + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes stored hash before the validator + // runs. + $this->addListener($event_class, function () use ($state_key) { + $this->container->get('state')->delete($state_key); + }); + $result = ValidationResult::createError([ + 'Could not retrieve stored hash of the active lock file.', + ]); + $this->assertResults([$result], $event_class); + } + + /** + * Data provider for test methods that validate the staging area. + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerValidateStageEvents(): array { + return [ + 'pre-require' => [ + PreRequireEvent::class, + ], + 'pre-apply' => [ + PreApplyEvent::class, + ], + ]; + } + + /** + * Creates a 'composer.lock' file in the active directory. + */ + private function createActiveLockFile(): void { + $lock_file = vfsStream::newFile('composer.lock')->setContent('{}'); + $this->vfsRoot->addChild($lock_file); + } + + /** + * Adds an event listener with the highest possible priority. + * + * @param string $event_class + * The event class to listen for. + * @param callable $listener + * The listener to add. + */ + private function addListener(string $event_class, callable $listener): void { + $this->container->get('event_dispatcher') + ->addListener($event_class, $listener, PHP_INT_MAX); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..414d3512422949c9a8908ab43744355bf30d6ebe --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -0,0 +1,136 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\Stage; +use Drupal\Tests\package_manager\Traits\ValidationTestTrait; + +/** + * Base class for kernel tests of Package Manager's functionality. + */ +abstract class PackageManagerKernelTestBase extends KernelTestBase { + + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + $this->disableValidators($container); + } + + /** + * Disables any validators that will interfere with this test. + */ + protected function disableValidators(ContainerBuilder $container): void { + // Disable the filesystem permissions validator, since we cannot guarantee + // that the current code base will be writable in all testing situations. + // We test this validator functionally in Automatic Updates' build tests, + // since those do give us control over the filesystem permissions. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest + $container->removeDefinition('package_manager.validator.file_system'); + } + + /** + * Creates a stage object for testing purposes. + * + * @return \Drupal\Tests\package_manager\Kernel\TestStage + * A stage object, with test-only modifications. + */ + protected function createStage(): TestStage { + return new TestStage( + $this->container->get('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') + ); + } + + /** + * Asserts validation results are returned from a stage life cycle event. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param string|null $event_class + * (optional) The class of the event which should return the results. Must + * be passed if $expected_results is not empty. + */ + protected function assertResults(array $expected_results, string $event_class = NULL): void { + $stage = $this->createStage(); + + try { + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->destroy(); + + // If we did not get an exception, ensure we didn't expect any results. + $this->assertEmpty($expected_results); + } + catch (StageValidationException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->getResults()); + // TestStage::dispatch() attaches the event object to the exception so + // that we can analyze it. + $this->assertNotEmpty($event_class); + $this->assertInstanceOf($event_class, $e->event); + } + } + + /** + * Marks all pending post-update functions as completed. + * + * Since kernel tests don't normally install modules and register their + * updates, this method makes sure that we are testing from a clean, fully + * up-to-date state. + */ + protected function registerPostUpdateFunctions(): void { + $updates = $this->container->get('update.post_update_registry') + ->getPendingUpdateFunctions(); + + $this->container->get('keyvalue') + ->get('post_update') + ->set('existing_updates', $updates); + } + +} + +/** + * Defines a stage specifically for testing purposes. + */ +class TestStage extends Stage { + + /** + * {@inheritdoc} + */ + protected function dispatch(StageEvent $event): void { + try { + parent::dispatch($event); + } + catch (StageException $e) { + // Attach the event object to the exception so that test code can verify + // that the exception was thrown when a specific event was dispatched. + $e->event = $event; + throw $e; + } + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ea90029ca28aa001c035cb076bf868158b580dca --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\EventSubscriber\PendingUpdatesValidator + * + * @group package_manager + */ +class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system']; + + /** + * Tests that no error is raised if there are no pending updates. + */ + public function testNoPendingUpdates(): void { + $this->registerPostUpdateFunctions(); + $this->assertResults([], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending schema updates. + * + * @depends testNoPendingUpdates + */ + public function testPendingUpdateHook(): void { + // Register the System module's post-update functions, so that any detected + // pending updates are guaranteed to be schema updates. + $this->registerPostUpdateFunctions(); + + // Set the installed schema version of Package Manager to its default value + // and import an empty update hook which is numbered much higher than will + // ever exist in the real world. + $this->container->get('keyvalue') + ->get('system.schema') + ->set('package_manager', \Drupal::CORE_MINIMUM_SCHEMA_VERSION); + + require_once __DIR__ . '/../../fixtures/db_update.php'; + + $result = ValidationResult::createError([ + 'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.', + ]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending post-updates. + */ + public function testPendingPostUpdate(): void { + // The System module's post-update functions have not been registered, so + // the update registry will think they're pending. + $result = ValidationResult::createError([ + 'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.', + ]); + $this->assertResults([$result], PreCreateEvent::class); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ffa23a8a9cb61a2e4aac125629a81d17b8386062 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php @@ -0,0 +1,33 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests that Package Manager services are wired correctly. + * + * @group package_manager + */ +class ServicesTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager']; + + /** + * Tests that Package Manager's public services can be instantiated. + */ + public function testPackageManagerServices(): void { + $services = [ + 'package_manager.beginner', + 'package_manager.stager', + 'package_manager.committer', + ]; + foreach ($services as $service) { + $this->assertIsObject($this->container->get($service)); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..eb5f46c1b45bfd05691a3258960b5af4856082ef --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php @@ -0,0 +1,160 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreDestroyEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\Stage; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Tests that the staging area fires events during its lifecycle. + * + * @covers \Drupal\package_manager\Event\StageEvent + * + * @group package_manager + */ +class StageEventsTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + /** + * The events that were fired, in the order they were fired. + * + * @var string[] + */ + private $events = []; + + /** + * The stage under test. + * + * @var \Drupal\package_manager\Stage + */ + private $stage; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->stage = new Stage( + $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') + ); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'handleEvent', + PostCreateEvent::class => 'handleEvent', + PreRequireEvent::class => 'handleEvent', + PostRequireEvent::class => 'handleEvent', + PreApplyEvent::class => 'handleEvent', + PostApplyEvent::class => 'handleEvent', + PreDestroyEvent::class => 'handleEvent', + PostDestroyEvent::class => 'handleEvent', + ]; + } + + /** + * Handles a staging area life cycle event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function handleEvent(StageEvent $event): void { + array_push($this->events, get_class($event)); + + // The event should have a reference to the stage which fired it. + $this->assertSame($event->getStage(), $this->stage); + } + + /** + * Tests that the staging area fires life cycle events in a specific order. + */ + public function testEvents(): void { + $this->container->get('event_dispatcher')->addSubscriber($this); + + $this->stage->create(); + $this->stage->require(['ext-json:*']); + $this->stage->apply(); + $this->stage->destroy(); + + $this->assertSame($this->events, [ + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + PreDestroyEvent::class, + PostDestroyEvent::class, + ]); + } + + /** + * Data provider for ::testValidationResults(). + * + * @return string[][] + * Sets of arguments to pass to the test method. + */ + public function providerValidationResults(): array { + return [ + [PreCreateEvent::class], + [PreRequireEvent::class], + [PreApplyEvent::class], + [PreDestroyEvent::class], + ]; + } + + /** + * Tests that an exception is thrown if an event has validation results. + * + * @param string $event_class + * The event class to test. + * + * @dataProvider providerValidationResults + */ + public function testValidationResults(string $event_class): void { + // Set up an event listener which will only flag an error for the event + // class under test. + $handler = function (StageEvent $event) use ($event_class): void { + if (get_class($event) === $event_class) { + if ($event instanceof PreOperationStageEvent) { + $event->addError([['Burn, baby, burn']]); + } + } + }; + $this->container->get('event_dispatcher') + ->addListener($event_class, $handler); + + try { + $this->stage->create(); + $this->stage->require(['ext-json:*']); + $this->stage->apply(); + $this->stage->destroy(); + + $this->fail('Expected \Drupal\package_manager\Exception\StageValidationException to be thrown.'); + } + catch (StageValidationException $e) { + $this->assertCount(1, $e->getResults()); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php new file mode 100644 index 0000000000000000000000000000000000000000..99339a969614da6e496a947e2f282abb6ba5779b --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -0,0 +1,224 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager_test_validation\TestSubscriber; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests that ownership of the stage is enforced. + * + * @group package_manger + */ +class StageOwnershipTest extends PackageManagerKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'package_manager_test_validation', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installSchema('system', ['sequences']); + $this->installEntitySchema('user'); + $this->registerPostUpdateFunctions(); + } + + /** + * Tests only the owner of stage can perform operations, even if logged out. + */ + public function testOwnershipEnforcedWhenLoggedOut(): void { + $this->assertOwnershipIsEnforced($this->createStage(), $this->createStage()); + } + + /** + * Tests only the owner of stage can perform operations. + */ + public function testOwnershipEnforcedWhenLoggedIn(): void { + $user_1 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_1); + + $will_create = $this->createStage(); + // Rebuild the container so that the shared tempstore factory is made + // properly aware of the new current user ($user_2) before another stage + // is created. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $user_2 = $this->createUser(); + $this->setCurrentUser($user_2); + $this->assertOwnershipIsEnforced($will_create, $this->createStage()); + } + + /** + * Asserts that ownership is enforced across staging areas. + * + * @param \Drupal\Tests\package_manager\Kernel\TestStage $will_create + * The stage that will be created, and owned by the current user or session. + * @param \Drupal\Tests\package_manager\Kernel\TestStage $never_create + * The stage that will not be created, but should still respect the + * ownership and status of the other stage. + */ + private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $never_create): void { + // Before the staging area is created, isAvailable() should return TRUE. + $this->assertTrue($will_create->isAvailable()); + $this->assertTrue($never_create->isAvailable()); + + $stage_id = $will_create->create(); + // Both staging areas should be considered unavailable (i.e., cannot be + // created until the existing one is destroyed first). + $this->assertFalse($will_create->isAvailable()); + $this->assertFalse($never_create->isAvailable()); + + // We should get an error if we try to create the staging area again, + // regardless of who owns it. + foreach ([$will_create, $never_create] as $stage) { + try { + $stage->create(); + $this->fail("Able to create a stage that already exists."); + } + catch (StageException $exception) { + $this->assertSame('Cannot create a new stage because one already exists.', $exception->getMessage()); + } + } + + try { + $never_create->claim($stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + + // Only the stage's owner should be able to move it through its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + try { + $never_create->$method(...$arguments); + $this->fail("Able to call '$method' on a stage that was never created."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // The call should succeed on the created stage. + $will_create->$method(...$arguments); + } + } + + /** + * Tests behavior of claiming a stage. + */ + public function testClaim(): void { + // Log in as a user so that any stage instances created during the session + // should be able to successfully call ::claim(). + $user_2 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_2); + $creator_stage = $this->createStage(); + + // Ensure that exceptions thrown during ::create() will not lock the stage. + $error = new \Exception('I am going to stop stage creation.'); + TestSubscriber::setException($error, PreCreateEvent::class); + try { + $creator_stage->create(); + $this->fail('Was able to create the stage despite throwing an exception in pre-create.'); + } + catch (\RuntimeException $exception) { + $this->assertSame($error->getMessage(), $exception->getMessage()); + } + + // The stage should be available, and throw if we try to claim it. + $this->assertTrue($creator_stage->isAvailable()); + try { + $creator_stage->claim('any-id-would-fail'); + $this->fail('Was able to claim a stage that has not been created.'); + } + catch (StageException $exception) { + $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage()); + } + TestSubscriber::setException(NULL, PreCreateEvent::class); + + // Even if we own the stage, we should not be able to claim it with an + // incorrect ID. + $stage_id = $creator_stage->create(); + try { + $this->createStage()->claim('not-correct-id'); + $this->fail('Was able to claim an owned stage with an incorrect ID.'); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because the current lock does not match the stored lock.', $exception->getMessage()); + } + + // A stage that is successfully claimed should be able to call any method + // for its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + // Create a new stage instance for each method. + $this->createStage()->claim($stage_id)->$method(...$arguments); + } + + // The stage cannot be claimed after it's been destroyed. + try { + $this->createStage()->claim($stage_id); + $this->fail('Was able to claim an owned stage after it was destroyed.'); + } + catch (StageException $exception) { + $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage()); + } + + // Create a new stage and then log in as a different user. + $new_stage_id = $this->createStage()->create(); + $user_3 = $this->createUser([], NULL, FALSE, ['uid' => 3]); + $this->setCurrentUser($user_3); + + // Even if they use the correct stage ID, the current user cannot claim a + // stage they didn't create. + try { + $this->createStage()->claim($new_stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + } + + /** + * Tests a stage being destroyed by a user who doesn't own it. + */ + public function testForceDestroy(): void { + $owned = $this->createStage(); + $owned->create(); + + $not_owned = $this->createStage(); + try { + $not_owned->destroy(); + $this->fail("Able to destroy a stage that we don't own."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // We should be able to destroy the stage if we ignore ownership. + $not_owned->destroy(TRUE); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0ed5f56049ab936218670b10af52aa2ec41e2e3c --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +/** + * @coversDefaultClass \Drupal\package_manager\Stage + * + * @group package_manager + */ +class StageTest extends PackageManagerKernelTestBase { + + /** + * @covers ::getStageDirectory + */ + public function testGetStageDirectory(): void { + $stage = $this->createStage(); + $id = $stage->create(); + $this->assertStringEndsWith("/.package_manager/$id", $stage->getStageDirectory()); + } + + /** + * @covers ::getStageDirectory + */ + public function testUncreatedGetStageDirectory(): void { + $this->expectException('LogicException'); + $this->expectExceptionMessage('Drupal\package_manager\Stage::getStageDirectory() cannot be called because the stage has not been created or claimed.'); + $this->createStage()->getStageDirectory(); + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d5db9b1afbb01f7a357817e1c220b1c38cd05b31 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php @@ -0,0 +1,137 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\EventSubscriber\WritableFileSystemValidator; +use Drupal\package_manager\ValidationResult; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\PathLocator; +use org\bovigo\vfs\vfsStream; + +/** + * Unit tests the file system permissions validator. + * + * This validator is tested functionally in Automatic Updates' build tests, + * since those give us control over the file system permissions. + * + * @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + * + * @covers \Drupal\package_manager\EventSubscriber\WritableFileSystemValidator + * + * @group package_manager + */ +class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // Replace the file system permissions validator with our test-only + // implementation. + $container->getDefinition('package_manager.validator.file_system') + ->setClass(TestWritableFileSystemValidator::class); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + // Disable the disk space validator, since it tries to inspect the file + // system in ways that vfsStream doesn't support, like calling stat() and + // disk_free_space(). + $container->removeDefinition('package_manager.validator.disk_space'); + + // Disable the lock file validator, since the mock file system we create in + // this test doesn't have any lock files to validate. + $container->removeDefinition('package_manager.validator.lock_file'); + } + + /** + * Data provider for ::testWritable(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerWritable(): array { + $root_error = t('The Drupal directory "vfs://root" is not writable.'); + $vendor_error = t('The vendor directory "vfs://root/vendor" is not writable.'); + $summary = t('The file system is not writable.'); + $writable_permission = 0777; + $non_writable_permission = 0444; + + return [ + 'root and vendor are writable' => [ + $writable_permission, + $writable_permission, + [], + ], + 'root writable, vendor not writable' => [ + $writable_permission, + $non_writable_permission, + [ + ValidationResult::createError([$vendor_error], $summary), + ], + ], + 'root not writable, vendor writable' => [ + $non_writable_permission, + $writable_permission, + [ + ValidationResult::createError([$root_error], $summary), + ], + ], + 'nothing writable' => [ + $non_writable_permission, + $non_writable_permission, + [ + ValidationResult::createError([$root_error, $vendor_error], $summary), + ], + ], + ]; + } + + /** + * Tests the file system permissions validator. + * + * @param int $root_permissions + * The file permissions for the root folder. + * @param int $vendor_permissions + * The file permissions for the vendor folder. + * @param array $expected_results + * The expected validation results. + * + * @dataProvider providerWritable + */ + public function testWritable(int $root_permissions, int $vendor_permissions, array $expected_results): void { + $root = vfsStream::setup('root', $root_permissions); + $vendor = vfsStream::newDirectory('vendor', $vendor_permissions); + $root->addChild($vendor); + + $path_locator = $this->prophesize(PathLocator::class); + $path_locator->getActiveDirectory()->willReturn($root->url()); + $path_locator->getWebRoot()->willReturn(''); + $path_locator->getVendorDirectory()->willReturn($vendor->url()); + $this->container->set('package_manager.path_locator', $path_locator->reveal()); + + /** @var \Drupal\Tests\package_manager\Kernel\TestWritableFileSystemValidator $validator */ + $validator = $this->container->get('package_manager.validator.file_system'); + $validator->appRoot = $root->url(); + + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} + +/** + * A test version of the file system permissions validator. + */ +class TestWritableFileSystemValidator extends WritableFileSystemValidator { + + /** + * {@inheritdoc} + */ + public $appRoot; + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..9cfeff13a2b8a16b7d659dc35441f9abb3201855 --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\Tests\package_manager\Traits; + +/** + * Contains helpful methods for testing stage validators. + */ +trait ValidationTestTrait { + + /** + * Asserts two validation result sets are equal. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param \Drupal\package_manager\ValidationResult[] $actual_results + * The actual validation results. + */ + protected function assertValidationResultsEqual(array $expected_results, array $actual_results): void { + $this->assertCount(count($expected_results), $actual_results); + + foreach ($expected_results as $expected_result) { + $actual_result = array_shift($actual_results); + $this->assertSame($expected_result->getSeverity(), $actual_result->getSeverity()); + $this->assertSame((string) $expected_result->getSummary(), (string) $actual_result->getSummary()); + $this->assertSame( + array_map('strval', $expected_result->getMessages()), + array_map('strval', $actual_result->getMessages()) + ); + } + } + +} diff --git a/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php new file mode 100644 index 0000000000000000000000000000000000000000..26de5cd6e78afa16bb4232dd8ff78549bb58888b --- /dev/null +++ b/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php @@ -0,0 +1,100 @@ +<?php + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\ValidationResult; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\ValidationResult + * + * @group package_manager + */ +class ValidationResultTest extends UnitTestCase { + + /** + * @covers ::createWarning + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateWarningResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createWarning($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING); + } + + /** + * @covers ::createError + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateErrorResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createError($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR); + } + + /** + * @covers ::createWarning + */ + public function testCreateWarningResultException(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('If more than one message is provided, a summary is required.'); + ValidationResult::createWarning(['Something is wrong', 'Something else is also wrong'], NULL); + } + + /** + * @covers ::createError + */ + public function testCreateErrorResultException(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('If more than one message is provided, a summary is required.'); + ValidationResult::createError(['Something is wrong', 'Something else is also wrong'], NULL); + } + + /** + * Data provider for testCreateWarningResult(). + * + * @return mixed[] + * Test cases for testCreateWarningResult(). + */ + public function providerValidConstructorArguments(): array { + return [ + '1 message no summary' => [ + 'messages' => ['Something is wrong'], + 'summary' => NULL, + ], + '2 messages has summary' => [ + 'messages' => ['Something is wrong', 'Something else is also wrong'], + 'summary' => 'This sums it up.', + ], + ]; + } + + /** + * Asserts a check result is valid. + * + * @param \Drupal\package_manager\ValidationResult $result + * The validation result to check. + * @param array $expected_messages + * The expected messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The expected summary or NULL if not summary is expected. + * @param int $severity + * The severity. + */ + protected function assertResultValid(ValidationResult $result, array $expected_messages, ?TranslatableMarkup $summary, int $severity): void { + $this->assertSame($expected_messages, $result->getMessages()); + if ($summary === NULL) { + $this->assertNull($result->getSummary()); + } + else { + $this->assertSame($summary->getUntranslatedString(), $result->getSummary() + ->getUntranslatedString()); + } + $this->assertSame($severity, $result->getSeverity()); + } + +} diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..475aecefeb2811cf92298da8ed0f52a6d8c866a4 --- /dev/null +++ b/core/modules/auto_updates/src/BatchProcessor.php @@ -0,0 +1,193 @@ +<?php + +namespace Drupal\auto_updates; + +use Drupal\Core\Url; +use Drupal\package_manager\Exception\StageValidationException; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * A batch processor for updates. + */ +class BatchProcessor { + + /** + * Gets the updater service. + * + * @return \Drupal\auto_updates\Updater + * The updater service. + */ + protected static function getUpdater(): Updater { + return \Drupal::service('auto_updates.updater'); + } + + /** + * Records messages from a throwable, then re-throws it. + * + * @param \Throwable $error + * The caught exception. + * @param array $context + * The current context of the batch job. + * + * @throws \Throwable + * The caught exception, which will always be re-thrown once its messages + * have been recorded. + */ + protected static function handleException(\Throwable $error, array &$context): void { + $error_messages = [ + $error->getMessage(), + ]; + + if ($error instanceof StageValidationException) { + foreach ($error->getResults() as $result) { + $messages = $result->getMessages(); + if (count($messages) > 1) { + array_unshift($messages, $result->getSummary()); + } + $error_messages = array_merge($error_messages, $messages); + } + } + + foreach ($error_messages as $error_message) { + $context['results']['errors'][] = $error_message; + } + throw $error; + } + + /** + * Calls the updater's begin() method. + * + * @param string[] $project_versions + * The project versions to be staged in the update, keyed by package name. + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\auto_updates\Updater::begin() + */ + public static function begin(array $project_versions, array &$context): void { + try { + $stage_unique = static::getUpdater()->begin($project_versions); + $context['results']['stage_id'] = $stage_unique; + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + + /** + * Calls the updater's stageVersions() method. + * + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\auto_updates\Updater::stage() + */ + public static function stage(array &$context): void { + try { + $stage_id = $context['results']['stage_id']; + static::getUpdater()->claim($stage_id)->stage(); + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + + /** + * Calls the updater's commit() method. + * + * @param string $stage_id + * The stage ID. + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\auto_updates\Updater::apply() + */ + public static function commit(string $stage_id, array &$context): void { + try { + static::getUpdater()->claim($stage_id)->apply(); + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + + /** + * Calls the updater's clean() method. + * + * @param string $stage_id + * The stage ID. + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\auto_updates\Updater::clean() + */ + public static function clean(string $stage_id, array &$context): void { + try { + static::getUpdater()->claim($stage_id)->destroy(); + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + + /** + * Finishes the stage batch job. + * + * @param bool $success + * Indicate that 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. + */ + public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse { + if ($success) { + $url = Url::fromRoute('auto_updates.confirmation_page', [ + 'stage_id' => $results['stage_id'], + ]); + return new RedirectResponse($url->setAbsolute()->toString()); + } + static::handleBatchError($results); + return NULL; + } + + /** + * Finishes the commit batch job. + * + * @param bool $success + * Indicate that 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. + */ + public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse { + + if ($success) { + \Drupal::messenger()->addMessage('Update complete!'); + // @todo redirect to update.php? + return new RedirectResponse(Url::fromRoute('update.status', [], + ['absolute' => TRUE])->toString()); + } + static::handleBatchError($results); + return NULL; + } + + /** + * Handles a batch job that finished with errors. + * + * @param array $results + * The batch results. + */ + protected static function handleBatchError(array $results): void { + if (isset($results['errors'])) { + foreach ($results['errors'] as $error) { + \Drupal::messenger()->addError($error); + } + } + else { + \Drupal::messenger()->addError("Update error"); + } + } + +} diff --git a/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php new file mode 100644 index 0000000000000000000000000000000000000000..adb05762b9450b3bb5342f01d90b8be49a500b3c --- /dev/null +++ b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php @@ -0,0 +1,90 @@ +<?php + +namespace Drupal\auto_updates\Controller; + +use Drupal\auto_updates\Validation\ReadinessValidationManager; +use Drupal\auto_updates\Validation\ReadinessTrait; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * A controller for running readiness checkers. + * + * @internal + * Controller classes are internal. + */ +class ReadinessCheckerController extends ControllerBase { + + use ReadinessTrait; + + /** + * The readiness checker manager. + * + * @var \Drupal\auto_updates\Validation\ReadinessValidationManager + */ + protected $readinessCheckerManager; + + /** + * Constructs a ReadinessCheckerController object. + * + * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager + * The readiness checker manager. + * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation + * The string translation service. + */ + public function __construct(ReadinessValidationManager $checker_manager, TranslationInterface $string_translation) { + $this->readinessCheckerManager = $checker_manager; + $this->setStringTranslation($string_translation); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get('auto_updates.readiness_validation_manager'), + $container->get('string_translation'), + ); + } + + /** + * Run the readiness checkers. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect to the status report page. + */ + public function run(): RedirectResponse { + $results = $this->readinessCheckerManager->run()->getResults(); + if (!$results) { + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + // If there are no messages from the readiness checkers display a message + // that the site is ready. If there are messages, the status report will + // display them. + $this->messenger()->addStatus($this->t('No issues found. Your site is ready for automatic updates')); + } + else { + // Determine if any of the results are errors. + $error_results = $this->readinessCheckerManager->getResults(SystemManager::REQUIREMENT_ERROR); + // If there are any errors, display a failure message as an error. + // Otherwise, display it as a warning. + $severity = $error_results ? SystemManager::REQUIREMENT_ERROR : SystemManager::REQUIREMENT_WARNING; + $failure_message = $this->getFailureMessageForSeverity($severity); + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $this->messenger()->addError($failure_message); + } + else { + $this->messenger()->addWarning($failure_message); + } + } + // Set a redirect to the status report page. Any other page that provides a + // link to this controller should include 'destination' in the query string + // to ensure this redirect is overridden. + // @see \Drupal\Core\EventSubscriber\RedirectResponseSubscriber::checkRedirectUrl() + return $this->redirect('system.status'); + } + +} diff --git a/core/modules/auto_updates/src/CronUpdater.php b/core/modules/auto_updates/src/CronUpdater.php new file mode 100644 index 0000000000000000000000000000000000000000..bae5fc8539a5403300d5126b6ab602b748c3fdc7 --- /dev/null +++ b/core/modules/auto_updates/src/CronUpdater.php @@ -0,0 +1,151 @@ +<?php + +namespace Drupal\auto_updates; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a service that updates via cron. + * + * @internal + * This class implements logic specific to Automatic Updates' cron hook + * implementation. It should not be called directly. + */ +class CronUpdater implements ContainerInjectionInterface { + + /** + * All automatic updates are disabled. + * + * @var string + */ + public const DISABLED = 'disable'; + + /** + * Only perform automatic security updates. + * + * @var string + */ + public const SECURITY = 'security'; + + /** + * All automatic updates are enabled. + * + * @var string + */ + public const ALL = 'patch'; + + /** + * The updater service. + * + * @var \Drupal\auto_updates\Updater + */ + protected $updater; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a CronUpdater object. + * + * @param \Drupal\auto_updates\Updater $updater + * The updater service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory + * The logger channel factory. + */ + public function __construct(Updater $updater, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) { + $this->updater = $updater; + $this->configFactory = $config_factory; + $this->logger = $logger_factory->get('auto_updates'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('auto_updates.updater'), + $container->get('config.factory'), + $container->get('logger.factory') + ); + } + + /** + * 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) { + 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; + } + + $project = $recommender->getProjectInfo(); + if (empty($project['existing_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(); + try { + $this->updater->begin([ + 'drupal' => $recommended_version, + ]); + $this->updater->stage(); + $this->updater->apply(); + $this->updater->destroy(); + } + catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + return; + } + + $this->logger->info( + 'Drupal core has been updated from %previous_version to %update_version', + [ + '%previous_version' => $project['existing_version'], + '%update_version' => $recommended_version, + ] + ); + } + +} diff --git a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..a961b2b2ed6b756c0ec86fda08baadf57ef0c027 --- /dev/null +++ b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php @@ -0,0 +1,56 @@ +<?php + +namespace Drupal\auto_updates\Event; + +use Drupal\auto_updates\Updater; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\package_manager\Event\PreCreateEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber to exclude certain paths from update operations. + */ +class ExcludedPathsSubscriber implements EventSubscriberInterface { + + /** + * The module list service. + * + * @var \Drupal\Core\Extension\ModuleExtensionList + */ + protected $moduleList; + + /** + * Constructs an UpdateSubscriber. + * + * @param \Drupal\Core\Extension\ModuleExtensionList $module_list + * The module list service. + */ + public function __construct(ModuleExtensionList $module_list) { + $this->moduleList = $module_list; + } + + /** + * Reacts to the beginning of an update process. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event object. + */ + public function preCreate(PreCreateEvent $event): void { + // If we are doing an automatic update and this module is a git clone, + // exclude it. + if ($event->getStage() instanceof Updater && is_dir(__DIR__ . '/../../.git')) { + $dir = $this->moduleList->getPath('auto_updates'); + $event->excludePath($dir); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'preCreate', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..a1d2bfd9b42fd91275f18982400e7f5faa681a4a --- /dev/null +++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php @@ -0,0 +1,62 @@ +<?php + +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\ValidationResult; + +/** + * Event fired when checking if the site could perform an update. + * + * An update is not actually being started when this event is being fired. It + * should be used to notify site admins if the site is in a state which will + * not allow automatic updates to succeed. + * + * This event should only be dispatched from ReadinessValidationManager to + * allow caching of the results. + * + * @see \Drupal\auto_updates\Validation\ReadinessValidationManager + */ +class ReadinessCheckEvent extends PreOperationStageEvent { + + /** + * The desired package versions to update to, keyed by package name. + * + * @var string[] + */ + protected $packageVersions; + + /** + * Constructs a ReadinessCheckEvent object. + * + * @param \Drupal\auto_updates\Updater $updater + * The updater service. + * @param string[] $package_versions + * (optional) The desired package versions to update to, keyed by package + * name. + */ + public function __construct(Updater $updater, array $package_versions = []) { + parent::__construct($updater); + $this->packageVersions = $package_versions; + } + + /** + * Returns the desired package versions to update to. + * + * @return string[] + * The desired package versions to update to, keyed by package name. + */ + public function getPackageVersions(): array { + return $this->packageVersions; + } + + /** + * {@inheritdoc} + */ + public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL) { + $this->results[] = ValidationResult::createWarning($messages, $summary); + } + +} diff --git a/core/modules/auto_updates/src/Exception/UpdateException.php b/core/modules/auto_updates/src/Exception/UpdateException.php new file mode 100644 index 0000000000000000000000000000000000000000..98cc013f0aae3df5462fec27c8f2dc048dbb422a --- /dev/null +++ b/core/modules/auto_updates/src/Exception/UpdateException.php @@ -0,0 +1,11 @@ +<?php + +namespace Drupal\auto_updates\Exception; + +use Drupal\package_manager\Exception\StageValidationException; + +/** + * Defines a custom exception for a failure during an update. + */ +class UpdateException extends StageValidationException { +} diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php new file mode 100644 index 0000000000000000000000000000000000000000..7a293aad812809476e5fdfb73283f47aa2ed28f3 --- /dev/null +++ b/core/modules/auto_updates/src/Form/UpdateReady.php @@ -0,0 +1,132 @@ +<?php + +namespace Drupal\auto_updates\Form; + +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\Updater; +use Drupal\Core\Batch\BatchBuilder; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Exception\StageOwnershipException; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a form to commit staged updates. + * + * @internal + * Form classes are internal. + */ +class UpdateReady extends FormBase { + + /** + * The updater service. + * + * @var \Drupal\auto_updates\Updater + */ + protected $updater; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * Constructs a new UpdateReady object. + * + * @param \Drupal\auto_updates\Updater $updater + * The updater service. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(Updater $updater, MessengerInterface $messenger, StateInterface $state) { + $this->updater = $updater; + $this->setMessenger($messenger); + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'auto_updates_update_ready_form'; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('auto_updates.updater'), + $container->get('messenger'), + $container->get('state') + ); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, string $stage_id = NULL) { + try { + $this->updater->claim($stage_id); + } + catch (StageOwnershipException $e) { + $this->messenger()->addError($this->t('Cannot continue the update because another Composer operation is currently in progress.')); + return $form; + } + + $form['stage_id'] = [ + '#type' => 'value', + '#value' => $stage_id, + ]; + + $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']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Continue'), + ]; + + return $form; + } + + /** + * {@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->state->set('system.maintenance_mode', TRUE); + // @todo unset after updater. After db update? + } + $stage_id = $form_state->getValue('stage_id'); + $batch = (new BatchBuilder()) + ->setTitle($this->t('Apply updates')) + ->setInitMessage($this->t('Preparing to apply updates')) + ->addOperation([BatchProcessor::class, 'commit'], [$stage_id]) + ->addOperation([BatchProcessor::class, 'clean'], [$stage_id]) + ->setFinishCallback([BatchProcessor::class, 'finishCommit']) + ->toArray(); + + batch_set($batch); + } + +} diff --git a/core/modules/auto_updates/src/Form/UpdaterForm.php b/core/modules/auto_updates/src/Form/UpdaterForm.php new file mode 100644 index 0000000000000000000000000000000000000000..ff5899e9492e4945e8e26f8cd27ab3c23ece04a9 --- /dev/null +++ b/core/modules/auto_updates/src/Form/UpdaterForm.php @@ -0,0 +1,259 @@ +<?php + +namespace Drupal\auto_updates\Form; + +use Drupal\auto_updates\BatchProcessor; +use Drupal\auto_updates\Updater; +use Drupal\auto_updates\UpdateRecommender; +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\system\SystemManager; +use Drupal\update\UpdateManagerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a form to update Drupal core. + * + * @internal + * Form classes are internal. + */ +class UpdaterForm extends FormBase { + + /** + * The updater service. + * + * @var \Drupal\auto_updates\Updater + */ + protected $updater; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The readiness validation manager service. + * + * @var \Drupal\auto_updates\Validation\ReadinessValidationManager + */ + protected $readinessValidationManager; + + /** + * Constructs a new UpdaterForm object. + * + * @param \Drupal\Core\State\StateInterface $state + * 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. + */ + public function __construct(StateInterface $state, Updater $updater, ReadinessValidationManager $readiness_validation_manager) { + $this->updater = $updater; + $this->state = $state; + $this->readinessValidationManager = $readiness_validation_manager; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'auto_updates_updater_form'; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('state'), + $container->get('auto_updates.updater'), + $container->get('auto_updates.readiness_validation_manager') + ); + } + + /** + * {@inheritdoc} + */ + 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.')); + + $form['last_check'] = [ + '#theme' => 'update_last_check', + '#last' => $this->state->get('update.last_check', 0), + ]; + + $recommender = new UpdateRecommender(); + try { + $recommended_release = $recommender->getRecommendedRelease(TRUE); + } + catch (\RuntimeException $e) { + $form['message'] = [ + '#markup' => $e->getMessage(), + ]; + return $form; + } + + // @todo Should we be using the Update module's library here, or our own? + $form['#attached']['library'][] = 'update/drupal.update.admin'; + + // If we're already up-to-date, there's nothing else we need to do. + if ($recommended_release === NULL) { + $this->messenger()->addMessage('No update available'); + return $form; + } + + $form['update_version'] = [ + '#type' => 'value', + '#value' => [ + 'drupal' => $recommended_release->getVersion(), + ], + ]; + + $project = $recommender->getProjectInfo(); + if (empty($project['title']) || empty($project['link'])) { + throw new \UnexpectedValueException('Expected project data to have a title and link.'); + } + $title = Link::fromTextAndUrl($project['title'], Url::fromUri($project['link']))->toRenderable(); + + switch ($project['status']) { + case UpdateManagerInterface::NOT_SECURE: + case UpdateManagerInterface::REVOKED: + $title['#suffix'] = ' ' . $this->t('(Security update)'); + $type = 'update-security'; + break; + + case UpdateManagerInterface::NOT_SUPPORTED: + $title['#suffix'] = ' ' . $this->t('(Unsupported)'); + $type = 'unsupported'; + break; + + default: + $type = 'recommended'; + break; + } + + // Create an entry for this project. + $entry = [ + 'title' => [ + 'data' => $title, + ], + 'installed_version' => $project['existing_version'], + 'recommended_version' => [ + 'data' => [ + // @todo Is an inline template the right tool here? Is there an Update + // module template we should use instead? + '#type' => 'inline_template', + '#template' => '{{ release_version }} (<a href="{{ release_link }}" title="{{ project_title }}">{{ release_notes }}</a>)', + '#context' => [ + 'release_version' => $recommended_release->getVersion(), + 'release_link' => $recommended_release->getReleaseUrl(), + 'project_title' => $this->t('Release notes for @project_title', ['@project_title' => $project['title']]), + 'release_notes' => $this->t('Release notes'), + ], + ], + ], + ]; + + $form['projects'] = [ + '#type' => 'table', + '#header' => [ + 'title' => [ + 'data' => $this->t('Name'), + 'class' => ['update-project-name'], + ], + 'installed_version' => $this->t('Installed version'), + 'recommended_version' => [ + 'data' => $this->t('Recommended version'), + ], + ], + '#rows' => [ + 'drupal' => [ + 'class' => "update-$type", + 'data' => $entry, + ], + ], + ]; + + // @todo Add a hasErrors() or getErrors() method to + // ReadinessValidationManager to make validation more introspectable. + // Re-running the readiness checks now should mean that when we display + // cached errors in auto_updates_page_top(), we'll see errors that + // were raised during this run, instead of any previously cached results. + $errors = $this->readinessValidationManager->run() + ->getResults(SystemManager::REQUIREMENT_ERROR); + + if (empty($errors)) { + $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 + // 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'] = [ + '#type' => 'submit', + '#value' => $this->t('Delete existing update'), + '#submit' => ['::deleteExistingUpdate'], + ]; + } + else { + $actions['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Update'), + ]; + } + return $actions; + } + + /** + * 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")); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $batch = (new BatchBuilder()) + ->setTitle($this->t('Downloading updates')) + ->setInitMessage($this->t('Preparing to download updates')) + ->addOperation( + [BatchProcessor::class, 'begin'], + [$form_state->getValue('update_version')] + ) + ->addOperation([BatchProcessor::class, 'stage']) + ->setFinishCallback([BatchProcessor::class, 'finishStage']) + ->toArray(); + + batch_set($batch); + } + +} diff --git a/core/modules/auto_updates/src/UpdateRecommender.php b/core/modules/auto_updates/src/UpdateRecommender.php new file mode 100644 index 0000000000000000000000000000000000000000..adf09b2ee1f12c5e08e8209ffffba6261283febf --- /dev/null +++ b/core/modules/auto_updates/src/UpdateRecommender.php @@ -0,0 +1,69 @@ +<?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 new file mode 100644 index 0000000000000000000000000000000000000000..a5745c766b2c310f0dd4c572c1aad5e1f117c6af --- /dev/null +++ b/core/modules/auto_updates/src/Updater.php @@ -0,0 +1,103 @@ +<?php + +namespace Drupal\auto_updates; + +use Drupal\auto_updates\Exception\UpdateException; +use Drupal\package_manager\ComposerUtility; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\Stage; + +/** + * Defines a service to perform updates. + */ +class Updater extends Stage { + + /** + * Begins the update. + * + * @param string[] $project_versions + * The versions of the packages to update to, keyed by package name. + * + * @return string + * The unique ID of the stage. + * + * @throws \InvalidArgumentException + * Thrown if no project version for Drupal core is provided. + */ + public function begin(array $project_versions): string { + if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) { + throw new \InvalidArgumentException("Currently only updates to Drupal core are supported."); + } + + $composer = ComposerUtility::createForDirectory($this->pathLocator->getActiveDirectory()); + $package_versions = [ + 'production' => [], + 'dev' => [], + ]; + + foreach ($composer->getCorePackageNames() as $package) { + $package_versions['production'][$package] = $project_versions['drupal']; + } + foreach ($composer->getCoreDevPackageNames() as $package) { + $package_versions['dev'][$package] = $project_versions['drupal']; + } + + // Ensure that package versions are available to pre-create event + // subscribers. We can't use ::setMetadata() here because it requires the + // stage to be claimed, but that only happens during ::create(). + $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [ + 'packages' => $package_versions, + ]); + return $this->create(); + } + + /** + * Returns the package versions that will be required during the update. + * + * @return string[][] + * An array with two sub-arrays: 'production' and 'dev'. Each is a set of + * package versions, where the keys are package names and the values are + * version constraints understood by Composer. + */ + public function getPackageVersions(): array { + return $this->getMetadata('packages'); + } + + /** + * Stages the update. + */ + public function stage(): void { + $this->checkOwnership(); + + // Convert an associative array of package versions, keyed by name, to + // command-line arguments in the form `vendor/name:version`. + $map = function (array $versions): array { + $requirements = []; + foreach ($versions as $package => $version) { + $requirements[] = "$package:$version"; + } + return $requirements; + }; + $versions = array_map($map, $this->getPackageVersions()); + + $this->require($versions['production']); + + if ($versions['dev']) { + $this->require($versions['dev'], TRUE); + } + } + + /** + * {@inheritdoc} + */ + protected function dispatch(StageEvent $event): void { + try { + parent::dispatch($event); + } + 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 new file mode 100644 index 0000000000000000000000000000000000000000..0878e0e3e8c37dcfb321c4e99c2c629fd8db9f6e --- /dev/null +++ b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php @@ -0,0 +1,185 @@ +<?php + +namespace Drupal\auto_updates\Validation; + +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Messenger\MessengerTrait; +use Drupal\Core\Routing\AdminContext; +use Drupal\Core\Routing\CurrentRouteMatch; +use Drupal\Core\Routing\RedirectDestinationTrait; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\Url; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Class for displaying readiness messages on admin pages. + * + * @internal + * This class implements logic to output the messages from readiness checkers + * on admin pages. It should not be called directly. + */ +final class AdminReadinessMessages implements ContainerInjectionInterface { + + use MessengerTrait; + use StringTranslationTrait; + use RedirectDestinationTrait; + use ReadinessTrait; + + /** + * The readiness checker manager. + * + * @var \Drupal\auto_updates\Validation\ReadinessValidationManager + */ + protected $readinessCheckerManager; + + /** + * The admin context service. + * + * @var \Drupal\Core\Routing\AdminContext + */ + protected $adminContext; + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ + protected $currentUser; + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\CurrentRouteMatch + */ + protected $currentRouteMatch; + + /** + * Constructs a ReadinessRequirement object. + * + * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_checker_manager + * The readiness checker manager service. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \Drupal\Core\Routing\AdminContext $admin_context + * The admin context service. + * @param \Drupal\Core\Session\AccountProxyInterface $current_user + * The current user. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match + * The current route match. + */ + public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match) { + $this->readinessCheckerManager = $readiness_checker_manager; + $this->setMessenger($messenger); + $this->adminContext = $admin_context; + $this->currentUser = $current_user; + $this->setStringTranslation($translation); + $this->currentRouteMatch = $current_route_match; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get('auto_updates.readiness_validation_manager'), + $container->get('messenger'), + $container->get('router.admin_context'), + $container->get('current_user'), + $container->get('string_translation'), + $container->get('current_route_match') + ); + } + + /** + * Displays the checker results messages on admin pages. + */ + public function displayAdminPageMessages(): void { + if (!$this->displayResultsOnCurrentPage()) { + return; + } + if ($this->readinessCheckerManager->getResults() === NULL) { + $checker_url = Url::fromRoute('auto_updates.update_readiness')->setOption('query', $this->getDestinationArray()); + if ($checker_url->access()) { + $this->messenger()->addError($this->t('Your site has not recently run an update readiness check. <a href=":url">Run readiness checks now.</a>', [ + ':url' => $checker_url->toString(), + ])); + } + } + else { + // Display errors, if there are any. If there aren't, then display + // warnings, if there are any. + if (!$this->displayResultsForSeverity(SystemManager::REQUIREMENT_ERROR)) { + $this->displayResultsForSeverity(SystemManager::REQUIREMENT_WARNING); + } + } + } + + /** + * Determines whether the messages should be displayed on the current page. + * + * @return bool + * Whether the messages should be displayed on the current page. + */ + protected function displayResultsOnCurrentPage(): bool { + 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', + ]; + return !in_array($this->currentRouteMatch->getRouteName(), $disabled_routes, TRUE); + } + return FALSE; + } + + /** + * Displays the results for severity. + * + * @param int $severity + * The severity for the results to display. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return bool + * Whether any results were displayed. + */ + protected function displayResultsForSeverity(int $severity): bool { + $results = $this->readinessCheckerManager->getResults($severity); + if (empty($results)) { + return FALSE; + } + $failure_message = $this->getFailureMessageForSeverity($severity); + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $this->messenger()->addError($failure_message); + } + else { + $this->messenger()->addWarning($failure_message); + } + + foreach ($results as $result) { + $messages = $result->getMessages(); + $message = count($messages) === 1 ? $messages[0] : $result->getSummary(); + if ($severity === SystemManager::REQUIREMENT_ERROR) { + $this->messenger()->addError($message); + } + else { + $this->messenger()->addWarning($message); + } + } + return TRUE; + } + +} diff --git a/core/modules/auto_updates/src/Validation/ReadinessRequirements.php b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php new file mode 100644 index 0000000000000000000000000000000000000000..9a1e706264be12c588d7b92b85b22ed5cc0dc97f --- /dev/null +++ b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php @@ -0,0 +1,173 @@ +<?php + +namespace Drupal\auto_updates\Validation; + +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\Url; +use Drupal\system\SystemManager; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Class for generating the readiness checkers' output for hook_requirements(). + * + * @see auto_updates_requirements() + * + * @internal + * This class implements logic to output the messages from readiness checkers + * on the status report page. It should not be called directly. + */ +final class ReadinessRequirements implements ContainerInjectionInterface { + + use StringTranslationTrait; + use ReadinessTrait; + + /** + * The readiness checker manager. + * + * @var \Drupal\auto_updates\Validation\ReadinessValidationManager + */ + protected $readinessCheckerManager; + + /** + * The date formatter service. + * + * @var \Drupal\Core\Datetime\DateFormatterInterface + */ + protected $dateFormatter; + + /** + * Constructor ReadinessRequirement object. + * + * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_checker_manager + * The readiness checker manager service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter + * The date formatter service. + */ + public function __construct(ReadinessValidationManager $readiness_checker_manager, TranslationInterface $translation, DateFormatterInterface $date_formatter) { + $this->readinessCheckerManager = $readiness_checker_manager; + $this->setStringTranslation($translation); + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get('auto_updates.readiness_validation_manager'), + $container->get('string_translation'), + $container->get('date.formatter') + ); + } + + /** + * Gets requirements arrays as specified in hook_requirements(). + * + * @return array[] + * Requirements arrays as specified by hook_requirements(). + */ + public function getRequirements(): array { + $results = $this->readinessCheckerManager->runIfNoStoredResults()->getResults(); + $requirements = []; + if (empty($results)) { + $requirements['auto_updates_readiness'] = [ + 'title' => $this->t('Update readiness checks'), + 'severity' => SystemManager::REQUIREMENT_OK, + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + 'value' => $this->t('Your site is ready for automatic updates.'), + ]; + $run_link = $this->createRunLink(); + if ($run_link) { + $requirements['auto_updates_readiness']['description'] = $run_link; + } + } + else { + foreach ([SystemManager::REQUIREMENT_WARNING, SystemManager::REQUIREMENT_ERROR] as $severity) { + if ($requirement = $this->createRequirementForSeverity($severity)) { + $requirements["auto_updates_readiness_$severity"] = $requirement; + } + } + } + return $requirements; + } + + /** + * Creates a requirement for checker results of a specific severity. + * + * @param int $severity + * The severity for requirement. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return mixed[]|null + * Requirements array as specified by hook_requirements(), or NULL + * if no requirements can be determined. + */ + protected function createRequirementForSeverity(int $severity): ?array { + $severity_messages = []; + $results = $this->readinessCheckerManager->getResults($severity); + if (!$results) { + return NULL; + } + foreach ($results as $result) { + $checker_messages = $result->getMessages(); + if (count($checker_messages) === 1) { + $severity_messages[] = ['#markup' => array_pop($checker_messages)]; + } + else { + $severity_messages[] = [ + '#type' => 'details', + '#title' => $result->getSummary(), + '#open' => FALSE, + 'messages' => [ + '#theme' => 'item_list', + '#items' => $checker_messages, + ], + ]; + } + } + $requirement = [ + 'title' => $this->t('Update readiness checks'), + 'severity' => $severity, + 'value' => $this->getFailureMessageForSeverity($severity), + 'description' => [ + 'messages' => [ + '#theme' => 'item_list', + '#items' => $severity_messages, + ], + ], + ]; + if ($run_link = $this->createRunLink()) { + $requirement['description']['run_link'] = [ + '#type' => 'container', + '#markup' => $run_link, + ]; + } + return $requirement; + } + + /** + * Creates a link to run the readiness checkers. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null + * A link, if the user has access to run the readiness checkers, otherwise + * NULL. + */ + protected function createRunLink(): ?TranslatableMarkup { + $readiness_check_url = Url::fromRoute('auto_updates.update_readiness'); + if ($readiness_check_url->access()) { + return $this->t( + '<a href=":link">Run readiness checks</a> now.', + [':link' => $readiness_check_url->toString()] + ); + } + return NULL; + } + +} diff --git a/core/modules/auto_updates/src/Validation/ReadinessTrait.php b/core/modules/auto_updates/src/Validation/ReadinessTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..c7482c7a1e155df3181cc11b0d54302e5a306d11 --- /dev/null +++ b/core/modules/auto_updates/src/Validation/ReadinessTrait.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\auto_updates\Validation; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; + +/** + * Common methods for working with readiness checkers. + */ +trait ReadinessTrait { + + /** + * Gets a message, based on severity, when readiness checkers fail. + * + * @param int $severity + * The severity. Should be one of the SystemManager::REQUIREMENT_* + * constants. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The message. + * + * @see \Drupal\system\SystemManager::REQUIREMENT_ERROR + * @see \Drupal\system\SystemManager::REQUIREMENT_WARNING + */ + protected function getFailureMessageForSeverity(int $severity): TranslatableMarkup { + return $severity === SystemManager::REQUIREMENT_WARNING ? + // @todo Link "automatic updates" to documentation in + // https://www.drupal.org/node/3168405. + $this->t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.') : + $this->t('Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.'); + } + +} diff --git a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php new file mode 100644 index 0000000000000000000000000000000000000000..a169cd49728d4dc1fbfd1fb3523d61aec696f38d --- /dev/null +++ b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php @@ -0,0 +1,210 @@ +<?php + +namespace Drupal\auto_updates\Validation; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\Updater; +use Drupal\auto_updates\UpdateRecommender; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; +use Drupal\package_manager\ComposerUtility; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Defines a manager to run readiness validation. + */ +class ReadinessValidationManager { + + /** + * The key/value expirable storage. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface + */ + protected $keyValueExpirable; + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * The event dispatcher service. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * The number of hours to store results. + * + * @var int + */ + protected $resultsTimeToLive; + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + + /** + * The updater service. + * + * @var \Drupal\auto_updates\Updater + */ + protected $updater; + + /** + * Constructs a ReadinessValidationManager. + * + * @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory + * The key/value expirable factory. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher + * The event dispatcher service. + * @param \Drupal\auto_updates\Updater $updater + * The updater service. + * @param int $results_time_to_live + * The number of hours to store results. + */ + public function __construct(KeyValueExpirableFactoryInterface $key_value_expirable_factory, TimeInterface $time, PathLocator $path_locator, EventDispatcherInterface $dispatcher, Updater $updater, int $results_time_to_live) { + $this->keyValueExpirable = $key_value_expirable_factory->get('auto_updates'); + $this->time = $time; + $this->pathLocator = $path_locator; + $this->eventDispatcher = $dispatcher; + $this->updater = $updater; + $this->resultsTimeToLive = $results_time_to_live; + } + + /** + * Dispatches the readiness check event and stores the results. + * + * @return $this + */ + public function run(): self { + $composer = ComposerUtility::createForDirectory($this->pathLocator->getActiveDirectory()); + + $recommender = new UpdateRecommender(); + $release = $recommender->getRecommendedRelease(TRUE); + if ($release) { + $core_packages = $composer->getCorePackageNames(); + // Update all core packages to the same version. + $package_versions = array_fill(0, count($core_packages), $release->getVersion()); + $package_versions = array_combine($core_packages, $package_versions); + } + else { + $package_versions = []; + } + $event = new ReadinessCheckEvent($this->updater, $package_versions); + $this->eventDispatcher->dispatch($event); + $results = $event->getResults(); + $this->keyValueExpirable->setWithExpire( + 'readiness_validation_last_run', + [ + 'results' => $results, + 'listeners' => $this->getListenersAsString(ReadinessCheckEvent::class), + ], + $this->resultsTimeToLive * 60 * 60 + ); + $this->keyValueExpirable->set('readiness_check_timestamp', $this->time->getRequestTime()); + return $this; + } + + /** + * Gets all the listeners for a specific event as single string. + * + * @return string + * The listeners as a string. + */ + protected function getListenersAsString(string $event_name): string { + $listeners = $this->eventDispatcher->getListeners($event_name); + $string = ''; + foreach ($listeners as $listener) { + /** @var object $object */ + $object = $listener[0]; + $method = $listener[1]; + $string .= '-' . get_class($object) . '::' . $method; + } + return $string; + } + + /** + * Dispatches the readiness check event if there no stored valid results. + * + * @return $this + * + * @see self::getResults() + * @see self::getStoredValidResults() + */ + public function runIfNoStoredResults(): self { + if ($this->getResults() === NULL) { + $this->run(); + } + return $this; + } + + /** + * Gets the validation results from the last run. + * + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return \Drupal\package_manager\ValidationResult[]| + * The validation result objects or NULL if no results are + * available or if the stored results are no longer valid. + * + * @see self::getStoredValidResults() + */ + public function getResults(?int $severity = NULL): ?array { + $results = $this->getStoredValidResults(); + if ($results !== NULL) { + if ($severity !== NULL) { + $results = array_filter($results, function ($result) use ($severity) { + return $result->getSeverity() === $severity; + }); + } + return $results; + } + return NULL; + } + + /** + * Gets stored valid results, if any. + * + * The stored results are considered valid if the current listeners for the + * readiness check event are the same as the last time the event was + * dispatched. + * + * @return \Drupal\package_manager\ValidationResult[]|null + * The stored results if available and still valid, otherwise null. + */ + protected function getStoredValidResults(): ?array { + $last_run = $this->keyValueExpirable->get('readiness_validation_last_run'); + + // If the listeners have not changed return the results. + if ($last_run && $last_run['listeners'] === $this->getListenersAsString(ReadinessCheckEvent::class)) { + return $last_run['results']; + } + return NULL; + } + + /** + * Gets the timestamp of the last run. + * + * @return int|null + * The timestamp of the last completed run, or NULL if no run has + * been completed. + */ + public function getLastRunTime(): ?int { + return $this->keyValueExpirable->get('readiness_check_timestamp'); + } + +} diff --git a/core/modules/auto_updates/src/Validator/CoreComposerValidator.php b/core/modules/auto_updates/src/Validator/CoreComposerValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..43ae5582f377d1593d31e0a7230239b942fff390 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/CoreComposerValidator.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the Drupal core requirements defined in composer.json. + */ +class CoreComposerValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Validates the Drupal core requirements in composer.json. + * + * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event + * The event object. + */ + public function checkCoreRequirements(ReadinessCheckEvent $event): void { + // Ensure that either drupal/core or drupal/core-recommended is required. + // If neither is, then core cannot be updated, which we consider an error + // condition. + $core_requirements = array_intersect( + $event->getStage()->getActiveComposer()->getCorePackageNames(), + ['drupal/core', 'drupal/core-recommended'] + ); + if (empty($core_requirements)) { + $event->addError([ + $this->t('Drupal core does not appear to be required in the project-level composer.json.'), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + ReadinessCheckEvent::class => ['checkCoreRequirements', 1000], + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..1f86460908fff0d10b388bc6cebc65eb77faad85 --- /dev/null +++ b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php @@ -0,0 +1,180 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\CronUpdater; +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\Url; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that cron runs frequently enough to perform automatic updates. + */ +class CronFrequencyValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The error-level interval between cron runs, in seconds. + * + * If cron runs less frequently than this, an error will be raised during + * validation. Defaults to 24 hours. + * + * @var int + */ + protected const ERROR_INTERVAL = 86400; + + /** + * The warning-level interval between cron runs, in seconds. + * + * If cron runs less frequently than this, a warning will be raised during + * validation. Defaults to 3 hours. + * + * @var int + */ + protected const WARNING_INTERVAL = 10800; + + /** + * The cron frequency, in hours, to suggest in errors or warnings. + * + * @var int + */ + protected const SUGGESTED_INTERVAL = self::WARNING_INTERVAL / 3600; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * CronFrequencyValidator constructor. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, StateInterface $state, TimeInterface $time, TranslationInterface $translation) { + $this->configFactory = $config_factory; + $this->moduleHandler = $module_handler; + $this->state = $state; + $this->time = $time; + $this->setStringTranslation($translation); + } + + /** + * Validates that cron runs frequently enough to perform automatic updates. + * + * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event + * The event object. + */ + public function checkCronFrequency(ReadinessCheckEvent $event): void { + $cron_enabled = $this->configFactory->get('auto_updates.settings') + ->get('cron'); + + // If automatic updates are disabled during cron, there's nothing we need + // to validate. + if ($cron_enabled === CronUpdater::DISABLED) { + return; + } + elseif ($this->moduleHandler->moduleExists('automated_cron')) { + $this->validateAutomatedCron($event); + } + else { + $this->validateLastCronRun($event); + } + } + + /** + * Validates the cron frequency according to Automated Cron settings. + * + * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event + * The event object. + */ + protected function validateAutomatedCron(ReadinessCheckEvent $event): void { + $message = $this->t('Cron is not set to run frequently enough. <a href=":configure">Configure it</a> to run at least every @frequency hours or disable automated cron and run it via an external scheduling system.', [ + ':configure' => Url::fromRoute('system.cron_settings')->toString(), + '@frequency' => static::SUGGESTED_INTERVAL, + ]); + + $interval = $this->configFactory->get('automated_cron.settings')->get('interval'); + + if ($interval > static::ERROR_INTERVAL) { + $event->addError([$message]); + } + elseif ($interval > static::WARNING_INTERVAL) { + $event->addWarning([$message]); + } + } + + /** + * Validates the cron frequency according to the last cron run time. + * + * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event + * The event object. + */ + protected function validateLastCronRun(ReadinessCheckEvent $event): void { + // Determine when cron last ran. If not known, use the time that Drupal was + // installed, defaulting to the beginning of the Unix epoch. + $cron_last = $this->state->get('system.cron_last', $this->state->get('install_time', 0)); + + // @todo Should we allow a little extra time in case the server job takes + // longer than expected? Otherwise a server setup with a 3-hour cron job + // will always give this warning. Maybe this isn't necessary because the + // last cron run time is recorded after cron runs. Address this in + // https://www.drupal.org/project/auto_updates/issues/3248544. + if ($this->time->getRequestTime() - $cron_last > static::WARNING_INTERVAL) { + $event->addError([ + $this->t('Cron has not run recently. For more information, see the online handbook entry for <a href=":cron-handbook">configuring cron jobs</a> to run at least every @frequency hours.', [ + ':cron-handbook' => 'https://www.drupal.org/cron', + '@frequency' => static::SUGGESTED_INTERVAL, + ]), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + ReadinessCheckEvent::class => 'checkCronFrequency', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php new file mode 100644 index 0000000000000000000000000000000000000000..b8a6dbe9317365e5bbbfdaf698967e7d24d2d8ba --- /dev/null +++ b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * An adapter to run another stage validator during readiness checking. + * + * This class exists to facilitate re-use of Package Manager's stage validators + * during update readiness checks, in addition to whatever events they normally + * subscribe to. + */ +class PackageManagerReadinessCheck implements EventSubscriberInterface { + + /** + * The validator to run. + * + * @var \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface + */ + protected $validator; + + /** + * Constructs a PackageManagerReadinessCheck object. + * + * @param \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface $validator + * The Package Manager validator to run during readiness checking. + */ + public function __construct(PreOperationStageValidatorInterface $validator) { + $this->validator = $validator; + } + + /** + * Performs a readiness check by proxying to a Package Manager validator. + * + * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event + * The event object. + */ + public function validate(ReadinessCheckEvent $event): void { + $this->validator->validateStagePreOperation($event); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + ReadinessCheckEvent::class => 'validate', + ]; + } + +} diff --git a/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..f82b93b11234ffd72cd1088181e8a82928cca04f --- /dev/null +++ b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php @@ -0,0 +1,135 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Drupal\auto_updates\Updater; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates the staged Drupal projects. + */ +final class StagedProjectsValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a StagedProjectsValidation object. + * + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(TranslationInterface $translation) { + $this->setStringTranslation($translation); + } + + /** + * Validates the staged packages. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function validateStagedProjects(PreApplyEvent $event): void { + $stage = $event->getStage(); + // We only want to do this check if the stage belongs to Automatic Updates. + if (!$stage instanceof Updater) { + return; + } + + try { + $active_packages = $stage->getActiveComposer()->getDrupalExtensionPackages(); + $staged_packages = $stage->getStageComposer()->getDrupalExtensionPackages(); + } + catch (\Throwable $e) { + $event->addError([ + $e->getMessage(), + ]); + return; + } + + $type_map = [ + 'drupal-module' => $this->t('module'), + 'drupal-custom-module' => $this->t('custom module'), + 'drupal-theme' => $this->t('theme'), + 'drupal-custom-theme' => $this->t('custom theme'), + ]; + // Check if any new Drupal projects were installed. + if ($new_packages = array_diff_key($staged_packages, $active_packages)) { + $new_packages_messages = []; + + foreach ($new_packages as $new_package) { + $new_packages_messages[] = $this->t( + "@type '@name' installed.", + [ + '@type' => $type_map[$new_package->getType()], + '@name' => $new_package->getName(), + ] + ); + } + $new_packages_summary = $this->formatPlural( + count($new_packages_messages), + 'The update cannot proceed because the following Drupal project was installed during the update.', + 'The update cannot proceed because the following Drupal projects were installed during the update.' + ); + $event->addError($new_packages_messages, $new_packages_summary); + } + + // Check if any Drupal projects were removed. + if ($removed_packages = array_diff_key($active_packages, $staged_packages)) { + $removed_packages_messages = []; + foreach ($removed_packages as $removed_package) { + $removed_packages_messages[] = $this->t( + "@type '@name' removed.", + [ + '@type' => $type_map[$removed_package->getType()], + '@name' => $removed_package->getName(), + ] + ); + } + $removed_packages_summary = $this->formatPlural( + count($removed_packages_messages), + 'The update cannot proceed because the following Drupal project was removed during the update.', + 'The update cannot proceed because the following Drupal projects were removed during the update.' + ); + $event->addError($removed_packages_messages, $removed_packages_summary); + } + + // Get all the packages that are neither newly installed or removed to + // check if their version numbers changed. + if ($pre_existing_packages = array_diff_key($staged_packages, $removed_packages, $new_packages)) { + foreach ($pre_existing_packages as $package_name => $staged_existing_package) { + $active_package = $active_packages[$package_name]; + if ($staged_existing_package->getVersion() !== $active_package->getVersion()) { + $version_change_messages[] = $this->t( + "@type '@name' from @active_version to @staged_version.", + [ + '@type' => $type_map[$active_package->getType()], + '@name' => $active_package->getName(), + '@staged_version' => $staged_existing_package->getPrettyVersion(), + '@active_version' => $active_package->getPrettyVersion(), + ] + ); + } + } + if (!empty($version_change_messages)) { + $version_change_summary = $this->formatPlural( + count($version_change_messages), + 'The update cannot proceed because the following Drupal project was unexpectedly updated. Only Drupal Core updates are currently supported.', + 'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.' + ); + $event->addError($version_change_messages, $version_change_summary); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[PreApplyEvent::class][] = ['validateStagedProjects']; + return $events; + } + +} diff --git a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..d4f8079e782210a998f313365bbebec63aa6895d --- /dev/null +++ b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php @@ -0,0 +1,122 @@ +<?php + +namespace Drupal\auto_updates\Validator; + +use Composer\Semver\Semver; +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\Updater; +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 Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that core updates are within a supported version range. + */ +class UpdateVersionValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Constructs a UpdateVersionValidation object. + * + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The translation service. + */ + public function __construct(TranslationInterface $translation) { + $this->setStringTranslation($translation); + } + + /** + * Returns the running core version, according to the Update module. + * + * @return string + * 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']; + } + + /** + * Validates that core is not being updated to another minor or major version. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * 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) { + return; + } + + 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']; + } + + $from_version_string = $this->getCoreVersion(); + $from_version = ExtensionVersion::createFromVersionString($from_version_string); + $core_package_names = $stage->getActiveComposer()->getCorePackageNames(); + // 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 = reset($core_package_names); + $to_version_string = $package_versions[$core_package_name]; + $to_version = ExtensionVersion::createFromVersionString($to_version_string); + if (Semver::satisfies($to_version_string, "< $from_version_string")) { + $messages[] = $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', [ + '@to_version' => $to_version_string, + '@from_version' => $from_version_string, + ]); + $event->addError($messages); + } + elseif ($from_version->getVersionExtra() === 'dev') { + $messages[] = $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.', [ + '@to_version' => $to_version_string, + '@from_version' => $from_version_string, + ]); + $event->addError($messages); + } + elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { + $messages[] = $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.', [ + '@to_version' => $to_version_string, + '@from_version' => $from_version_string, + ]); + $event->addError($messages); + } + elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { + $messages[] = $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.', [ + '@from_version' => $this->getCoreVersion(), + '@to_version' => $package_versions[$core_package_name], + ]); + $event->addError($messages); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'checkUpdateVersion', + ReadinessCheckEvent::class => 'checkUpdateVersion', + ]; + } + +} diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.json b/core/modules/auto_updates/tests/fixtures/fake-site/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..ed5b67b7c8e9e583c7882d67592783157034c42d --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/composer.json @@ -0,0 +1,15 @@ +{ + "require": { + "drupal/test-distribution": "*" + }, + "require-dev": { + "drupal/core-dev": "^9" + }, + "extra": { + "_comment": [ + "This is a fake composer.json simulating a site which requires a distribution.", + "The required core packages are determined by scanning the lock file.", + "The required dev packages are determined by looking at the require-dev section of this file." + ] + } +} diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock b/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..dd29a0514d0ebdfc87484b52fc0c3256045ccb85 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock @@ -0,0 +1,23 @@ +{ + "packages": [ + { + "name": "drupal/test-distribution", + "version": "1.0.0", + "require": { + "drupal/core-recommended": "*" + } + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "require": { + "drupal/core": "9.8.0" + } + }, + { + "name": "drupal/core", + "version": "9.8.0" + } + ], + "packages-dev": [] +} diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..4f9da38be56281946118f811a12648ba1098dc02 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt @@ -0,0 +1 @@ +This private file should never be staged. diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..ab6a7649fa22b6d18458337f5e20802857106066 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt @@ -0,0 +1 @@ +This public file should never be staged. diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt new file mode 100644 index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt @@ -0,0 +1 @@ +This file should be staged. diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea9529af01099087a99ff93a5dee5bc3067032e6 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml @@ -0,0 +1,2 @@ +# A fake services file that should never be staged. +services: {} diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php new file mode 100644 index 0000000000000000000000000000000000000000..e54016a9d2c06052d463443c460fa28447460549 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * A fake local settings file that should never be staged. + */ diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php new file mode 100644 index 0000000000000000000000000000000000000000..9b995731d127cc38eaec5ca8a1d6d42c358ea0e9 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * A fake settings file that should never be staged. + */ diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt new file mode 100644 index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt @@ -0,0 +1 @@ +This file should be staged. diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt new file mode 100644 index 0000000000000000000000000000000000000000..e4525907f2639dbf494999ff10213d23f6709dc1 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt @@ -0,0 +1 @@ +This file should not be staged. diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..67668976a3a41b6dd7a23c72c3fd9e3e126aa0f8 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock @@ -0,0 +1,34 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/dev-removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..ba776321961b650a90d5459d0e5441b5a3575912 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock @@ -0,0 +1,43 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "drupal/test_module2", + "version": "1.3.1", + "type": "drupal-module" + }, + { + "name": "other/new_project", + "description": "This is newly added project but there should be no error because it is not a drupal project", + "version": "1.3.1", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "drupal/dev-test_module2", + "version": "1.3.1", + "type": "drupal-custom-module" + }, + { + "name": "other/dev-new_project", + "description": "This is newly added project but there should be no error because it is not a drupal project", + "version": "1.3.1", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..c6748473c811451d40f5b2e0b0dc5182d7238244 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json @@ -0,0 +1,15 @@ +{ + "require": { + "drupal/core-composer-scaffold": "*", + "drupal/core-vendor-hardening": "*", + "drupal/core-project-message": "*", + "drupal/core-dev": "*", + "drupal/core-dev-pinned": "*" + }, + "extra": { + "_comment": [ + "This is an example composer.json that does not require Drupal core.", + "@see \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\RequirementsValidatorTest" + ] + } +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..b44dcb4aea1835fe4164c00b3abdff1e29337a2f --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock @@ -0,0 +1,4 @@ +{ + "packages": [], + "packages-dev": [] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/.gitkeep b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..24e46dc84c259bf9a0edf3400294e65e73488615 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock @@ -0,0 +1,45 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + }, + { + "name": "other/changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/dev-removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + }, + { + "name": "other/dev-changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..5b77d4cbbd893f431d2b27e18efaf489988feea0 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock @@ -0,0 +1,45 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/new_project", + "description": "This is newly added project but there should be no error because it is not a drupal project", + "version": "1.3.1", + "type": "library" + }, + { + "name": "other/changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.2", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/dev-new_project", + "description": "This is newly added project but there should be no error because it is not a drupal project", + "version": "1.3.1", + "type": "library" + }, + { + "name": "other/dev-changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.2", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..ef86aa78d8df4942a0bcf8fe67d0561b044bfe62 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock @@ -0,0 +1,43 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, + { + "name": "drupal/test_theme", + "version": "1.3.0", + "type": "drupal-theme" + }, + { + "name": "drupal/test_module2", + "version": "1.3.1", + "type": "drupal-module" + }, + { + "name": "other/removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_theme", + "version": "1.3.0", + "type": "drupal-custom-theme" + }, + { + "name": "drupal/dev-test_module2", + "version": "1.3.1", + "type": "drupal-module" + }, + { + "name": "other/dev-removed", + "description": "This project is removed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..07773fab4cc96d6d4f41f2e0d0067e3fc51f4e59 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock @@ -0,0 +1,21 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, + { + "name": "drupal/test_module2", + "version": "1.3.1", + "type": "drupal-module" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module2", + "version": "1.3.1", + "type": "drupal-module" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..36980fee6ffcbaa1fc93d2c970f8a1d951dd4d2d --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock @@ -0,0 +1,33 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.0", + "type": "drupal-module" + }, + { + "name": "other/dev-changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.1", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock new file mode 100644 index 0000000000000000000000000000000000000000..c16841c9dd296bbf7cbeab7bd5373bd020e38890 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock @@ -0,0 +1,33 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, + { + "name": "drupal/test_module", + "version": "1.3.1", + "type": "drupal-module" + }, + { + "name": "other/changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.2", + "type": "library" + } + ], + "packages-dev": [ + { + "name": "drupal/dev-test_module", + "version": "1.3.1", + "type": "drupal-module" + }, + { + "name": "other/dev-changed", + "description": "This project version is changed but there should be no error because it is not a Drupal project.", + "version": "1.3.2", + "type": "library" + } + ] +} diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml new file mode 100644 index 0000000000000000000000000000000000000000..dbcb825a86d351652f72f2ff04b74fff9f8de8d8 --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml @@ -0,0 +1,40 @@ +<?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.8.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/drupal</link> + <terms> + <term><name>Projects</name><value>Drupal project</value></term> + </terms> +<releases> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Security update</value></term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Insecure</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml new file mode 100644 index 0000000000000000000000000000000000000000..4d5268378ef31b4e9c59766f2572dd76c30c730e --- /dev/null +++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml @@ -0,0 +1,38 @@ +<?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.8.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/drupal</link> + <terms> + <term><name>Projects</name><value>Drupal project</value></term> + </terms> +<releases> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..22479e83dbfe8b32534cd419c5447e98836e85b8 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml @@ -0,0 +1,7 @@ +name: 'Automatic Updates Test module 1' +type: module +description: 'Module for testing Automatic Updates.' +package: Testing +dependencies: + - drupal:auto_updates + - auto_updates:package_manager_test_validation diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..8c5c2a6988f0eba74dd9556ae9d84df912049f24 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml @@ -0,0 +1,13 @@ +auto_updates_test.metadata: + path: '/automatic-update-test/{project_name}/{version}' + defaults: + _title: 'Update test' + _controller: '\Drupal\auto_updates_test\TestController::metadata' + requirements: + _access: 'TRUE' +auto_updates_test.update: + path: '/automatic-update-test/update/{to_version}' + defaults: + _controller: '\Drupal\auto_updates_test\TestController::update' + requirements: + _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 new file mode 100644 index 0000000000000000000000000000000000000000..0902f6c78358f0025bba1558972f947920bab98f --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml @@ -0,0 +1,10 @@ +services: + auto_updates_test.checker: + class: Drupal\auto_updates_test\ReadinessChecker\TestChecker1 + tags: + - { name: event_subscriber } + arguments: ['@state'] + auto_updates_test.time: + class: Drupal\auto_updates_test\Datetime\TestTime + decorates: datetime.time + arguments: ['@auto_updates_test.time.inner','@request_stack'] diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php new file mode 100644 index 0000000000000000000000000000000000000000..c431159eedf84bf74df5173dec5b8abfcdcbc5ae --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\auto_updates_test\Datetime; + +use Drupal\Component\Datetime\Time; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Test service for altering the request time. + */ +class TestTime extends Time { + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\Time + */ + protected $decoratorTime; + + /** + * Constructs an Updater object. + * + * @param \Drupal\Component\Datetime\Time $time + * The time service. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The RequestStack object. + */ + public function __construct(Time $time, RequestStack $request_stack) { + $this->decoratorTime = $time; + parent::__construct($request_stack); + } + + /** + * {@inheritdoc} + */ + public function getRequestTime(): int { + if ($faked_date = \Drupal::state()->get('auto_updates_test.fake_date_time')) { + return \DateTime::createFromFormat('U', $faked_date)->getTimestamp(); + } + return $this->decoratorTime->getRequestTime(); + } + + /** + * Sets a fake time from an offset that will be used in the test. + * + * @param string $offset + * A date/time offset string as used by \DateTime::modify. + */ + public static function setFakeTimeByOffset(string $offset): void { + $fake_time = (new \DateTime())->modify($offset)->format('U'); + \Drupal::state()->set('auto_updates_test.fake_date_time', $fake_time); + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php new file mode 100644 index 0000000000000000000000000000000000000000..0eebfba434087bdb98c8be5cfdaffd1c9625b453 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php @@ -0,0 +1,22 @@ +<?php + +namespace Drupal\auto_updates_test\ReadinessChecker; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\package_manager_test_validation\TestSubscriber; + +/** + * A test readiness checker. + */ +class TestChecker1 extends TestSubscriber { + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events = parent::getSubscribedEvents(); + $events[ReadinessCheckEvent::class] = reset($events); + return $events; + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..3ad87828be6f0d7b70ab66e5aba2c16b43fc44c9 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php @@ -0,0 +1,94 @@ +<?php + +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; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response; + +class TestController extends ControllerBase { + + /** + * Performs an in-place update to a given version of Drupal core. + * + * This executes the update immediately, in one request, without using the + * batch system or cron as wrappers. + * + * @param string $to_version + * The version of core to update to. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response object. + */ + public function update(string $to_version): Response { + // Let it take as long as it needs. + Environment::setTimeLimit(0); + + /** @var \Drupal\auto_updates\Updater $updater */ + $updater = \Drupal::service('auto_updates.updater'); + try { + $updater->begin(['drupal' => $to_version]); + $updater->stage(); + $updater->apply(); + $updater->destroy(); + + $project = (new UpdateRecommender())->getProjectInfo(); + $content = $project['existing_version']; + $status = 200; + } + catch (UpdateException $e) { + $messages = []; + foreach ($e->getResults() as $result) { + if ($summary = $result->getSummary()) { + $messages[] = $summary; + } + $messages = array_merge($messages, $result->getMessages()); + } + $content = implode('<br />', $messages); + $status = 500; + } + return new HtmlResponse($content, $status); + } + + /** + * Page callback: Prints mock XML for the Update Manager module. + * + * This is a wholesale copy of + * \Drupal\update_test\Controller\UpdateTestController::updateTest() for + * testing automatic updates. This was done in order to use a different + * 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]; + } + elseif (isset($xml_map['#all'])) { + $availability_scenario = $xml_map['#all']; + } + else { + // The test didn't specify (for example, the webroot has other modules and + // themes installed but they're disabled by the version of the site + // running the test. So, we default to a file we know won't exist, so at + // least we'll get an empty xml response instead of a bunch of Drupal page + // output. + $availability_scenario = '#broken#'; + } + + $file = __DIR__ . "/../../../fixtures/release-history/$project_name.$availability_scenario.xml"; + $headers = ['Content-Type' => 'text/xml; charset=utf-8']; + if (!is_file($file)) { + // Return an empty response. + return new Response('', 200, $headers); + } + return new BinaryFileResponse($file, 200, $headers); + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..62a1f80a413fb773ad973766a6b969b99b03dbb2 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml @@ -0,0 +1,6 @@ +name: 'Automatic Updates Test module 2' +type: module +description: 'Test module to provide an additional readiness checker.' +package: Testing +dependencies: + - drupal:auto_updates_test diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..615204ebf9af9d9a28a2a1c26b7cc7e162b6ba75 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml @@ -0,0 +1,6 @@ +services: + auto_updates_test2.checker: + class: Drupal\auto_updates_test2\ReadinessChecker\TestChecker2 + tags: + - { name: event_subscriber } + arguments: ['@state'] diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php b/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php new file mode 100644 index 0000000000000000000000000000000000000000..1333e7c0a4eadb662e9b67f44a0d54d2ade9eaac --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php @@ -0,0 +1,23 @@ +<?php + +namespace Drupal\auto_updates_test2\ReadinessChecker; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates_test\ReadinessChecker\TestChecker1; +use Drupal\package_manager\Event\PreCreateEvent; + +/** + * A test readiness checker. + */ +class TestChecker2 extends TestChecker1 { + + protected const STATE_KEY = 'auto_updates_test2.checker_results'; + + public static function getSubscribedEvents() { + $events[ReadinessCheckEvent::class][] = ['addResults', 4]; + $events[PreCreateEvent::class][] = ['addResults', 4]; + + return $events; + } + +} diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..31c0d195f8debe32c65b9ff9b5eae5e77377f8e0 --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml @@ -0,0 +1,4 @@ +name: 'Automatic Updates Test: Disable validators' +description: Allows certain update validators to be disabled during testing. +type: module +package: Testing diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..450c9b7a7a61f9a438eaa3f431c7bc2efbb1fd6a --- /dev/null +++ b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php @@ -0,0 +1,26 @@ +<?php + +namespace Drupal\auto_updates_test_disable_validators; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\Core\Site\Settings; + +/** + * Allows specific validators to be disabled by site settings. + * + * This should only really be used by functional tests. Kernel tests should + * override their ::register() method to remove service definitions; build tests + * should stay out of the API/services layer unless absolutely necessary. + */ +class AutoUpdatesTestDisableValidatorsServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container) { + $validators = Settings::get('auto_updates_disable_validators', []); + array_walk($validators, [$container, 'removeDefinition']); + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0a71eecce2db81e377355d00e07138f132816550 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php @@ -0,0 +1,316 @@ +<?php + +namespace Drupal\Tests\auto_updates\Build; + +use Drupal\Component\Serialization\Json; + +/** + * Tests an end-to-end update of Drupal core. + * + * @group auto_updates + */ +class CoreUpdateTest extends UpdateTestBase { + + /** + * {@inheritdoc} + */ + protected function createTestSite(string $template): void { + $dir = $this->getWorkspaceDirectory(); + + // Build the test site and alter its copy of core so that it thinks it's + // running Drupal 9.8.0, which will never actually exist in the real world. + // Then, prepare a secondary copy of the core code base, masquerading as + // Drupal 9.8.1, which will be the version of core we update to. These two + // versions are referenced in the fake release metadata in our fake release + // metadata (see fixtures/release-history/drupal.0.0.xml). + parent::createTestSite($template); + $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0'); + $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1')); + + // Install Drupal and ensure it's using the fake release metadata to fetch + // information about available updates. + $this->installQuickStart('minimal'); + $this->setReleaseMetadata(['drupal' => '9.8.1-security']); + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->installModules([ + 'auto_updates', + 'auto_updates_test', + 'update_test', + ]); + + // If using the drupal/recommended-project template, we don't expect there + // to be an .htaccess file at the project root. One would normally be + // generated by Composer when Package Manager or other code creates a + // ComposerUtility object in the active directory, except that Package + // Manager takes specific steps to prevent that. So, here we're just + // confirming that, in fact, Composer's .htaccess protection was disabled. + // We don't do this for the drupal/legacy-project template because its + // project root, which is also the document root, SHOULD contain a .htaccess + // generated by Drupal core. + // We do this check because this test uses PHP's built-in web server, which + // ignores .htaccess files and everything in them, so a Composer-generated + // .htaccess file won't cause this test to fail. + if ($template === 'drupal/recommended-project') { + $this->assertFileNotExists($dir . '/.htaccess'); + } + + // Ensure that Drupal thinks we are running 9.8.0, then refresh information + // about available updates. + $this->assertCoreVersion('9.8.0'); + $this->checkForUpdates(); + // Ensure that an update to 9.8.1 is available. + $this->visit('/admin/modules/automatic-update'); + $this->getMink()->assertSession()->pageTextContains('9.8.1'); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if ($this->destroyBuild) { + $this->deleteCopiedPackages(); + } + parent::tearDown(); + } + + /** + * Modifies a Drupal core code base to set its version. + * + * @param string $dir + * The directory of the Drupal core code base. + * @param string $version + * The version number to set. + */ + private function setCoreVersion(string $dir, string $version): void { + $this->alterPackage($dir, ['version' => $version]); + + $drupal_php = "$dir/lib/Drupal.php"; + $this->assertIsWritable($drupal_php); + $code = file_get_contents($drupal_php); + $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code); + file_put_contents($drupal_php, $code); + } + + /** + * Returns composer.json changes that are needed to update core. + * + * This will clone the following packages into temporary directories: + * - drupal/core + * - drupal/core-recommended + * - drupal/core-project-message + * - drupal/core-composer-scaffold + * The cloned packages will be assigned the given version number, and the test + * site's composer.json will use the clones as path repositories. + * + * @param string $version + * The version of core we will be updating to. + * + * @return array + * The changes to merge into the test site's composer.json. + */ + protected function getConfigurationForUpdate(string $version): array { + $repositories = []; + + // Create a fake version of core with the given version number, and change + // its README so that we can actually be certain that we update to this + // fake version. + $dir = $this->copyPackage($this->getWebRoot() . '/core'); + $this->setCoreVersion($dir, $version); + file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version."); + $repositories['drupal/core'] = $this->createPathRepository($dir); + + $drupal_root = $this->getDrupalRoot(); + + // Create a fake version of drupal/core-recommended which itself requires + // the fake version of core we just created. + $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended"); + $this->alterPackage($dir, [ + 'require' => [ + 'drupal/core' => $version, + ], + 'version' => $version, + ]); + $repositories['drupal/core-recommended'] = $this->createPathRepository($dir); + + // Create fake target versions of core plugins and metapackages. + $packages = [ + 'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies", + 'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage", + 'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold", + 'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening", + ]; + foreach ($packages as $name => $dir) { + $dir = $this->copyPackage($dir); + $this->alterPackage($dir, ['version' => $version]); + $repositories[$name] = $this->createPathRepository($dir); + } + + return [ + 'repositories' => $repositories, + ]; + } + + /** + * Data provider for end-to-end update tests. + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerTemplate(): array { + return [ + ['drupal/recommended-project'], + ['drupal/legacy-project'], + ]; + } + + /** + * Tests an end-to-end core update via the API. + * + * @param string $template + * The template project from which to build the test site. + * + * @dataProvider providerTemplate + */ + public function testApi(string $template): void { + $this->createTestSite($template); + + $mink = $this->getMink(); + $assert_session = $mink->assertSession(); + + // Ensure that the update is prevented if the web root and/or vendor + // directories are not writable. + $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1'); + + $mink->getSession()->reload(); + $assert_session->pageTextContains('9.8.1'); + } + + /** + * Tests an end-to-end core update via the UI. + * + * @param string $template + * The template project from which to build the test site. + * + * @dataProvider providerTemplate + */ + public function testUi(string $template): void { + $this->createTestSite($template); + + $mink = $this->getMink(); + $session = $mink->getSession(); + $page = $session->getPage(); + $assert_session = $mink->assertSession(); + + $this->visit('/admin/modules'); + $assert_session->pageTextContains('There is a security update available for your version of Drupal.'); + $page->clickLink('Update'); + + // Ensure that the update is prevented if the web root and/or vendor + // directories are not writable. + $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH)); + $session->reload(); + + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + $page->pressButton('Update'); + $this->waitForBatchJob(); + $assert_session->pageTextContains('Ready to update'); + $page->pressButton('Continue'); + $this->waitForBatchJob(); + $assert_session->pageTextContains('Update complete!'); + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + $this->assertUpdateSuccessful(); + } + + /** + * Tests an end-to-end core update via cron. + * + * @param string $template + * The template project from which to build the test site. + * + * @dataProvider providerTemplate + */ + public function testCron(string $template): void { + $this->createTestSite($template); + + $this->visit('/admin/reports/status'); + $this->getMink()->getSession()->getPage()->clickLink('Run cron'); + $this->assertUpdateSuccessful(); + } + + /** + * Asserts that the update is prevented if the filesystem isn't writable. + * + * @param string $template + * The project template used to build the test site. See ::createTestSite() + * for the possible values. + * @param string $url + * A URL where we can see the error message which is raised when parts of + * the file system are not writable. This URL will be visited twice: once + * for the web root, and once for the vendor directory. + */ + private function assertReadOnlyFileSystemError(string $template, string $url): void { + $directories = [ + 'Drupal' => rtrim($this->getWebRoot(), './'), + ]; + + // The location of the vendor directory depends on which project template + // was used to build the test site. + if ($template === 'drupal/recommended-project') { + $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor'; + } + elseif ($template === 'drupal/legacy-project') { + $directories['vendor'] = $directories['Drupal'] . '/vendor'; + } + + $assert_session = $this->getMink()->assertSession(); + foreach ($directories as $type => $path) { + chmod($path, 0555); + $this->assertDirectoryIsNotWritable($path); + $this->visit($url); + $assert_session->pageTextContains("The $type directory \"$path\" is not writable."); + chmod($path, 0755); + $this->assertDirectoryIsWritable($path); + } + } + + /** + * Asserts that Drupal core was successfully updated. + */ + private function assertUpdateSuccessful(): void { + // The update form should not have any available updates. + // @todo Figure out why this assertion fails when the batch processor + // redirects directly to the update form, instead of update.status, when + // updating via the UI. + $this->visit('/admin/modules/automatic-update'); + $this->getMink()->assertSession()->pageTextContains('No update available'); + // The status page should report that we're running Drupal 9.8.1. + $this->assertCoreVersion('9.8.1'); + // The fake placeholder text from ::getConfigurationForUpdate() should be + // present in the README. + $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt'); + $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder); + + $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json'); + $composer = Json::decode($composer); + // The production dependencies should have been updated. + $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']); + $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']); + $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']); + // The core-vendor-hardening plugin is only used by the legacy project + // template. + if ($composer['name'] === 'drupal/legacy-project') { + $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']); + } + // The production dependencies should not be listed as dev dependencies. + $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']); + $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']); + $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']); + $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']); + + // The drupal/core-dev metapackage should not be a production dependency... + $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']); + // ...but it should have been updated in the dev dependencies. + $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']); + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..7bd97a62b0adccb787986d844c34356477ae4d6f --- /dev/null +++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php @@ -0,0 +1,318 @@ +<?php + +namespace Drupal\Tests\auto_updates\Build; + +use Drupal\BuildTests\QuickStart\QuickStartTestBase; +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\Html; +use Drupal\Tests\auto_updates\Traits\LocalPackagesTrait; +use Drupal\Tests\auto_updates\Traits\SettingsTrait; + +/** + * Base class for tests that perform in-place updates. + */ +abstract class UpdateTestBase extends QuickStartTestBase { + + use LocalPackagesTrait { + getPackagePath as traitGetPackagePath; + copyPackage as traitCopyPackage; + } + use SettingsTrait; + + /** + * A secondary server instance, to serve XML metadata about available updates. + * + * @var \Symfony\Component\Process\Process + */ + private $metadataServer; + + /** + * The test site's document root, relative to the workspace directory. + * + * @var string + * + * @see ::createTestSite() + */ + private $webRoot; + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if ($this->metadataServer) { + $this->metadataServer->stop(); + } + parent::tearDown(); + } + + /** + * {@inheritdoc} + */ + protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { + return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory()); + } + + /** + * {@inheritdoc} + */ + protected function getPackagePath(array $package): string { + if ($package['name'] === 'drupal/core') { + return 'core'; + } + + [$vendor, $name] = explode('/', $package['name']); + + // Assume any contributed module is in modules/contrib/$name. + if ($vendor === 'drupal' && $package['type'] === 'drupal-module') { + return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]); + } + + return $this->traitGetPackagePath($package); + } + + /** + * Returns the full path to the test site's document root. + * + * @return string + * The full path of the test site's document root. + */ + protected function getWebRoot(): string { + return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot; + } + + /** + * Prepares the test site to serve an XML feed of available release metadata. + * + * @param array $xml_map + * The update XML map, as used by update_test.settings. + * + * @see \Drupal\auto_updates_test\TestController::metadata() + */ + protected function setReleaseMetadata(array $xml_map): void { + $xml_map = var_export($xml_map, TRUE); + $code = <<<END +\$config['update_test.settings']['xml_map'] = $xml_map; +END; + + // When checking for updates, we need to be able to make sub-requests, but + // the built-in PHP server is single-threaded. Therefore, if needed, open a + // second server instance on another port, which will serve the metadata + // about available updates. + if (empty($this->metadataServer)) { + $port = $this->findAvailablePort(); + $this->metadataServer = $this->instantiateServer($port, $this->webRoot); + $code .= <<<END +\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test'; +END; + } + $this->addSettings($code, $this->getWebRoot()); + } + + /** + * {@inheritdoc} + */ + public function visit($request_uri = '/', $working_dir = NULL) { + return parent::visit($request_uri, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function formLogin($username, $password, $working_dir = NULL) { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function installQuickStart($profile, $working_dir = NULL) { + parent::installQuickStart($profile, $working_dir ?: $this->webRoot); + + // Always allow test modules to be installed in the UI and, for easier + // debugging, always display errors in their dubious glory. + $php = <<<END +\$settings['extension_discovery_scan_tests'] = TRUE; +\$config['system.logging']['error_level'] = 'verbose'; +END; + $this->addSettings($php, $this->getWebRoot()); + } + + /** + * Uses our already-installed dependencies to build a test site to update. + * + * @param string $template + * The template project from which to build the test site. Can be + * 'drupal/recommended-project' or 'drupal/legacy-project'. + */ + protected function createTestSite(string $template): void { + // Create the test site using one of the core project templates, but don't + // install dependencies just yet. + $template_dir = implode(DIRECTORY_SEPARATOR, [ + $this->getDrupalRoot(), + 'composer', + 'Template', + ]); + $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject'); + $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject'); + + $dir = $this->getWorkspaceDirectory(); + $command = sprintf( + "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'", + $template, + $dir, + Json::encode($recommended_template), + Json::encode($legacy_template) + ); + $this->executeCommand($command); + $this->assertCommandSuccessful(); + + $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json'; + $data = $this->readJson($composer); + + // Allow the test to configure the test site as necessary. + $data = $this->getInitialConfiguration($data); + + // We need to know the path of the web root, relative to the project root, + // in order to install Drupal or visit the test site at all. Luckily, both + // template projects define this because the scaffold plugin needs to know + // it as well. + // @see ::visit() + // @see ::formLogin() + // @see ::installQuickStart() + $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root']; + + // Update the test site's composer.json. + $this->writeJson($composer, $data); + // Install dependencies, including dev. + $this->executeCommand('composer install'); + $this->assertCommandSuccessful(); + } + + /** + * Returns the initial data to write to the test site's composer.json. + * + * This configuration will be used to build the pre-update test site. + * + * @param array $data + * The current contents of the test site's composer.json. + * + * @return array + * The data that should be written to the test site's composer.json. + */ + protected function getInitialConfiguration(array $data): array { + $drupal_root = $this->getDrupalRoot(); + $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer'; + $repositories = []; + + // Add all the metapackages that are provided by Drupal core. + $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage'; + $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended'); + $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies'); + + // Add all the Composer plugins that are provided by Drupal core. + $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin'; + $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage'); + $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold'); + $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening'); + + $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root)); + // To ensure the test runs entirely offline, don't allow Composer to contact + // Packagist. + $repositories['packagist.org'] = FALSE; + + $repositories['drupal/auto_updates'] = [ + 'type' => 'path', + 'url' => __DIR__ . '/../../..', + ]; + // Use whatever the current branch of auto_updates is. + $data['require']['drupal/auto_updates'] = '*'; + + $data['repositories'] = $repositories; + + // Since Drupal 9 requires PHP 7.3 or later, these packages are probably + // not installed, which can cause trouble during dependency resolution. + // The drupal/drupal package (defined with a composer.json that is part + // of core's repository) replaces these, so we need to emulate that here. + $data['replace']['symfony/polyfill-php72'] = '*'; + $data['replace']['symfony/polyfill-php73'] = '*'; + + return $data; + } + + /** + * Asserts that a specific version of Drupal core is running. + * + * Assumes that a user with permission to view the status report is logged in. + * + * @param string $expected_version + * The version of core that should be running. + */ + protected function assertCoreVersion(string $expected_version): void { + $this->visit('/admin/reports/status'); + $item = $this->getMink() + ->assertSession() + ->elementExists('css', 'h3:contains("Drupal Version")') + ->getParent() + ->getText(); + $this->assertStringContainsString($expected_version, $item); + } + + /** + * Installs modules in the UI. + * + * Assumes that a user with the appropriate permissions is logged in. + * + * @param string[] $modules + * The machine names of the modules to install. + */ + protected function installModules(array $modules): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $assert_session = $mink->assertSession(); + + $this->visit('/admin/modules'); + foreach ($modules as $module) { + $page->checkField("modules[$module][enable]"); + } + $page->pressButton('Install'); + + $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]') + ->getValue(); + if ($form_id === 'system_modules_confirm_form') { + $page->pressButton('Continue'); + $assert_session->statusCodeEquals(200); + } + } + + /** + * Checks for available updates. + * + * Assumes that a user with the appropriate access is logged in. + */ + protected function checkForUpdates(): void { + $this->visit('/admin/reports/updates'); + $this->getMink()->getSession()->getPage()->clickLink('Check manually'); + $this->waitForBatchJob(); + } + + /** + * Waits for an active batch job to finish. + */ + protected function waitForBatchJob(): void { + $refresh = $this->getMink() + ->getSession() + ->getPage() + ->find('css', 'meta[http-equiv="Refresh"], meta[http-equiv="refresh"]'); + + if ($refresh) { + // Parse the content attribute of the meta tag for the format: + // "[delay]: URL=[page_to_redirect_to]". + if (preg_match('/\d+;\s*URL=\'?(?<url>[^\']*)/i', $refresh->getAttribute('content'), $match)) { + $url = Html::decodeEntities($match['url']); + $this->visit($url); + $this->waitForBatchJob(); + } + } + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..1455257e575588772a93b8ad9618e82aadee715b --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php @@ -0,0 +1,92 @@ +<?php + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\Tests\BrowserTestBase; + +/** + * Base class for functional tests of the Automatic Updates module. + */ +abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates_test_disable_validators', + 'update', + 'update_test', + ]; + + /** + * {@inheritdoc} + */ + protected function prepareSettings() { + parent::prepareSettings(); + + // Disable the filesystem permissions validator, since we cannot guarantee + // that the current code base will be writable in all testing situations. We + // test this validator in our build tests, since those do give us control + // over the filesystem permissions. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + $settings['settings']['auto_updates_disable_validators'] = (object) [ + 'value' => [ + 'auto_updates.validator.file_system_permissions', + 'package_manager.validator.file_system', + ], + 'required' => TRUE, + ]; + $this->writeSettings($settings); + } + + /** + * Sets the current (running) version of core, as known to the Update module. + * + * @param string $version + * The current version of core. + */ + protected function setCoreVersion(string $version): void { + $this->config('update_test.settings') + ->set('system_info.#all.version', $version) + ->save(); + } + + /** + * Sets the release metadata file to use when fetching available updates. + * + * @param string $file + * The path of the XML metadata file to use. + */ + protected function setReleaseMetadata(string $file): void { + $this->config('update.settings') + ->set('fetch.url', $this->baseUrl . '/automatic-update-test') + ->save(); + + [$project, $fixture] = explode('.', basename($file, '.xml'), 2); + $this->config('update_test.settings') + ->set('xml_map', [ + $project => $fixture, + ]) + ->save(); + } + + /** + * Checks for available updates. + * + * Assumes that a user with appropriate permissions is logged in. + */ + protected function checkForUpdates(): void { + $this->drupalGet('/admin/reports/updates'); + $this->getSession()->getPage()->clickLink('Check manually'); + $this->checkForMetaRefresh(); + } + + /** + * Asserts that we are on the "update ready" form. + */ + protected function assertUpdateReady(): void { + $this->assertSession() + ->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php b/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d95a5c633fcb03e862c656e01001e896e219f287 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php @@ -0,0 +1,148 @@ +<?php + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates\Updater; +use Drupal\Core\Site\Settings; +use Drupal\package_manager\PathLocator; + +/** + * Tests handling of files and directories during an update. + * + * @group auto_updates + */ +class FileSystemOperationsTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['auto_updates_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * The updater service under test. + * + * @var \Drupal\Tests\auto_updates\Functional\TestUpdater + */ + private $updater; + + /** + * The full path of the staging directory. + * + * @var string + */ + protected $stageDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Create a mocked path locator that uses the fake site fixture as its + // active directory, and has a staging area within the site directory for + // this test. + $drupal_root = $this->getDrupalRoot(); + /** @var \Drupal\package_manager\PathLocator|\Prophecy\Prophecy\ObjectProphecy $locator */ + $locator = $this->prophesize(PathLocator::class); + $locator->getActiveDirectory()->willReturn(__DIR__ . '/../../fixtures/fake-site'); + $this->stageDir = implode(DIRECTORY_SEPARATOR, [ + $drupal_root, + $this->siteDirectory, + 'stage', + ]); + $locator->getProjectRoot()->willReturn($drupal_root); + $locator->getWebRoot()->willReturn(''); + + $this->updater = new TestUpdater( + $locator->reveal(), + $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->updater::$stagingRoot = $this->stageDir; + + // Use the public and private files directories in the fake site fixture. + $settings = Settings::getAll(); + $settings['file_public_path'] = 'files/public'; + $settings['file_private_path'] = 'files/private'; + new Settings($settings); + + // Updater::begin() will trigger update validators, such as + // \Drupal\auto_updates\Validator\UpdateVersionValidator, that need to + // fetch release metadata. We need to ensure that those HTTP request(s) + // succeed, so set them up to point to our fake release metadata. + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); + $this->setCoreVersion('9.8.0'); + + $this->drupalLogin($this->rootUser); + $this->checkForUpdates(); + $this->drupalLogout(); + } + + /** + * Tests that certain files and directories are not staged. + * + * @covers \Drupal\auto_updates\Updater::getExclusions + */ + public function testExclusions(): void { + $stage_id = $this->updater->begin(['drupal' => '9.8.1']); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.php"); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.local.php"); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/services.yml"); + // A file in sites/default, that isn't one of the site-specific settings + // files, should be staged. + $this->assertFileExists("$this->stageDir/$stage_id/sites/default/staged.txt"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/sites/simpletest"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/public"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/private"); + // A file that's in the general files directory, but not in the public or + // private directories, should be staged. + $this->assertFileExists("$this->stageDir/$stage_id/files/staged.txt"); + } + + /** + * Tests that the staging directory is properly cleaned up. + * + * @covers \Drupal\auto_updates\Cleaner + */ + public function testClean(): void { + $stage_id = $this->updater->begin(['drupal' => '9.8.1']); + // Make the staged site directory read-only, so we can test that it will be + // made writable on clean-up. + $this->assertTrue(chmod("$this->stageDir/$stage_id/sites/default", 0400)); + $this->assertNotIsWritable("$this->stageDir/$stage_id/sites/default/staged.txt"); + // If the site directory is not writable, this will throw an exception. + $this->updater->destroy(); + $this->assertDirectoryDoesNotExist($this->stageDir); + } + +} + +/** + * A test-only version of the updater. + */ +class TestUpdater extends Updater { + + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + protected static function getStagingRoot(): string { + return static::$stagingRoot ?: parent::getStagingRoot(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9c70a78bacdc7a362b28ea15fbecfb96910f7b1e --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php @@ -0,0 +1,495 @@ +<?php + +namespace Drupal\Tests\auto_updates\Functional; + +use Behat\Mink\Element\NodeElement; +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates_test\Datetime\TestTime; +use Drupal\auto_updates_test\ReadinessChecker\TestChecker1; +use Drupal\auto_updates_test2\ReadinessChecker\TestChecker2; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use Drupal\Tests\Traits\Core\CronRunTrait; + +/** + * Tests readiness validation. + * + * @group auto_updates + */ +class ReadinessValidationTest extends AutoUpdatesFunctionalTestBase { + + use StringTranslationTrait; + use CronRunTrait; + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * A user who can view the status report. + * + * @var \Drupal\user\Entity\User + */ + protected $reportViewerUser; + + /** + * A user who can view the status report and run readiness checkers. + * + * @var \Drupal\user\Entity\User + */ + protected $checkerRunnerUser; + + /** + * The test checker. + * + * @var \Drupal\auto_updates_test\ReadinessChecker\TestChecker1 + */ + protected $testChecker; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml'); + $this->setCoreVersion('9.8.1'); + + $this->reportViewerUser = $this->createUser([ + 'administer site configuration', + 'access administration pages', + ]); + $this->checkerRunnerUser = $this->createUser([ + 'administer site configuration', + 'administer software updates', + 'access administration pages', + ]); + $this->createTestValidationResults(); + $this->drupalLogin($this->reportViewerUser); + } + + /** + * Tests readiness checkers on status report page. + */ + public function testReadinessChecksStatusReport(): void { + $assert = $this->assertSession(); + + // Ensure automated_cron is disabled before installing auto_updates. This + // ensures we are testing that auto_updates runs the checkers when the + // module itself is installed and they weren't run on cron. + $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron')); + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + + // If the site is ready for updates, the users will see the same output + // regardless of whether the user has permission to run updates. + $this->drupalLogin($this->reportViewerUser); + $this->checkForUpdates(); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + // Confirm a user without the permission to run readiness checks does not + // have a link to run the checks when the checks need to be run again. + // @todo Change this to fake the request time in + // https://www.drupal.org/node/3113971. + /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ + $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates'); + $key_value->delete('readiness_validation_last_run'); + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + // Confirm a user with the permission to run readiness checks does have a + // link to run the checks when the checks need to be run again. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + /** @var \Drupal\package_manager\ValidationResult[] $expected_results */ + $expected_results = $this->testResults['checker_1']['1 error']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + + // Run the readiness checks. + $this->clickLink('Run readiness checks'); + $assert->statusCodeEquals(200); + // Confirm redirect back to status report page. + $assert->addressEquals('/admin/reports/status'); + // Assert that when the runners are run manually the message that updates + // will not be performed because of errors is displayed on the top of the + // page in message. + $assert->pageTextMatchesCount(2, '/' . preg_quote(static::$errorsExplanation) . '/'); + $this->assertErrors($expected_results, TRUE); + + // @todo Should we always show when the checks were last run and a link to + // run when there is an error? + // Confirm a user without permission to run the checks sees the same error. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertErrors($expected_results); + + $expected_results = $this->testResults['checker_1']['1 error 1 warning']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $key_value->delete('readiness_validation_last_run'); + // Confirm a new message is displayed if the stored messages are deleted. + $this->drupalGet('admin/reports/status'); + // Confirm that on the status page if there is only 1 warning or error the + // the summaries will not be displayed. + $this->assertErrors([$expected_results['1:error']]); + $this->assertWarnings([$expected_results['1:warning']]); + $assert->pageTextNotContains($expected_results['1:error']->getSummary()); + $assert->pageTextNotContains($expected_results['1:warning']->getSummary()); + + $key_value->delete('readiness_validation_last_run'); + $expected_results = $this->testResults['checker_1']['2 errors 2 warnings']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->drupalGet('admin/reports/status'); + // Confirm that both messages and summaries will be displayed on status + // report when there multiple messages. + $this->assertErrors([$expected_results['1:errors']]); + $this->assertWarnings([$expected_results['1:warnings']]); + + $key_value->delete('readiness_validation_last_run'); + $expected_results = $this->testResults['checker_1']['2 warnings']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->drupalGet('admin/reports/status'); + $assert->pageTextContainsOnce('Update readiness checks'); + // Confirm that warnings will display on the status report if there are no + // errors. + $this->assertWarnings($expected_results); + + $key_value->delete('readiness_validation_last_run'); + $expected_results = $this->testResults['checker_1']['1 warning']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->drupalGet('admin/reports/status'); + $assert->pageTextContainsOnce('Update readiness checks'); + $this->assertWarnings($expected_results); + } + + /** + * Tests readiness checkers results on admin pages.. + */ + public function testReadinessChecksAdminPages(): void { + $assert = $this->assertSession(); + $messages_section_selector = '[data-drupal-messages]'; + + // Ensure automated_cron is disabled before installing auto_updates. This + // ensures we are testing that auto_updates runs the checkers when the + // module itself is installed and they weren't run on cron. + $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron')); + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + + // If site is ready for updates no message will be displayed on admin pages. + $this->drupalLogin($this->reportViewerUser); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(); + $this->drupalGet('admin/structure'); + $assert->elementNotExists('css', $messages_section_selector); + + // Confirm a user without the permission to run readiness checks does not + // have a link to run the checks when the checks need to be run again. + $expected_results = $this->testResults['checker_1']['1 error']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + // @todo Change this to use ::delayRequestTime() to simulate running cron + // after a 24 wait instead of directly deleting 'readiness_validation_last_run' + // https://www.drupal.org/node/3113971. + /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ + $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates'); + $key_value->delete('readiness_validation_last_run'); + // A user without the permission to run the checkers will not see a message + // on other pages if the checkers need to be run again. + $this->drupalGet('admin/structure'); + $assert->elementNotExists('css', $messages_section_selector); + + // Confirm that a user with the correct permission can also run the checkers + // on another admin page. + $this->drupalLogin($this->checkerRunnerUser); + $this->drupalGet('admin/structure'); + $assert->elementExists('css', $messages_section_selector); + $assert->pageTextContainsOnce('Your site has not recently run an update readiness check. Run readiness checks now.'); + $this->clickLink('Run readiness checks now.'); + $assert->addressEquals('admin/structure'); + $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); + + $expected_results = $this->testResults['checker_1']['1 error 1 warning']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + // Confirm a new message is displayed if the cron is run after an hour. + $this->delayRequestTime(); + $this->cronRun(); + $this->drupalGet('admin/structure'); + $assert->pageTextContainsOnce(static::$errorsExplanation); + // Confirm on admin pages that a single error will be displayed instead of a + // summary. + $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:error']->getSeverity()); + $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]); + $assert->pageTextNotContains($expected_results['1:error']->getSummary()); + // Warnings are not displayed on admin pages if there are any errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warning']->getSeverity()); + $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]); + $assert->pageTextNotContains($expected_results['1:warning']->getSummary()); + + // Confirm that if cron runs less than hour after it previously ran it will + // not run the checkers again. + $unexpected_results = $this->testResults['checker_1']['2 errors 2 warnings']; + TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class); + $this->delayRequestTime(30); + $this->cronRun(); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary()); + $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]); + $assert->pageTextNotContains($unexpected_results['1:warnings']->getSummary()); + $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]); + + // Confirm that is if cron is run over an hour after the checkers were + // previously run the checkers will be run again. + $this->delayRequestTime(31); + $this->cronRun(); + $expected_results = $unexpected_results; + $this->drupalGet('admin/structure'); + // Confirm on admin pages only the error summary will be displayed if there + // is more than 1 error. + $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:errors']->getSeverity()); + $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[0]); + $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[1]); + $assert->pageTextContainsOnce($expected_results['1:errors']->getSummary()); + $assert->pageTextContainsOnce(static::$errorsExplanation); + // Warnings are not displayed on admin pages if there are any errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warnings']->getSeverity()); + $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[0]); + $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[1]); + $assert->pageTextNotContains($expected_results['1:warnings']->getSummary()); + + $expected_results = $this->testResults['checker_1']['2 warnings']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->delayRequestTime(); + $this->cronRun(); + $this->drupalGet('admin/structure'); + // Confirm that the warnings summary is displayed on admin pages if there + // are no errors. + $assert->pageTextNotContains(static::$errorsExplanation); + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity()); + $assert->pageTextNotContains($expected_results[0]->getMessages()[0]); + $assert->pageTextNotContains($expected_results[0]->getMessages()[1]); + $assert->pageTextContainsOnce(static::$warningsExplanation); + $assert->pageTextContainsOnce($expected_results[0]->getSummary()); + + $expected_results = $this->testResults['checker_1']['1 warning']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->delayRequestTime(); + $this->cronRun(); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains(static::$errorsExplanation); + // Confirm that a single warning is displayed and not the summary on admin + // pages if there is only 1 warning and there are no errors. + $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity()); + $assert->pageTextContainsOnce(static::$warningsExplanation); + $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); + $assert->pageTextNotContains($expected_results[0]->getSummary()); + } + + /** + * Tests installing a module with a checker before installing auto_updates. + */ + public function testReadinessCheckAfterInstall(): void { + $assert = $this->assertSession(); + $this->drupalLogin($this->checkerRunnerUser); + + $this->drupalGet('admin/reports/status'); + $assert->pageTextNotContains('Update readiness checks'); + + // We have to install the auto_updates_test module because it provides + // the functionality to retrieve our fake release history metadata. + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']); + $this->drupalGet('admin/reports/status'); + $this->assertNoErrors(TRUE); + + $expected_results = $this->testResults['checker_1']['1 error']; + TestChecker2::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates_test2']); + $this->drupalGet('admin/structure'); + $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); + + // Confirm that installing a module that does not provide a new checker does + // not run the checkers on install. + $unexpected_results = $this->testResults['checker_1']['2 errors 2 warnings']; + TestChecker2::setTestResult($unexpected_results, ReadinessCheckEvent::class); + $this->container->get('module_installer')->install(['help']); + // Check for message on 'admin/structure' instead of the status report + // because checkers will be run if needed on the status report. + $this->drupalGet('admin/structure'); + // Confirm that new checker message is not displayed because the checker was + // not run again. + $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); + $assert->pageTextNotContains($unexpected_results['1:errors']->getMessages()[0]); + $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary()); + } + + /** + * Tests that checker message for an uninstalled module is not displayed. + */ + public function testReadinessCheckerUninstall(): void { + $assert = $this->assertSession(); + $this->drupalLogin($this->checkerRunnerUser); + + $expected_results_1 = $this->testResults['checker_1']['1 error']; + TestChecker1::setTestResult($expected_results_1, ReadinessCheckEvent::class); + $expected_results_2 = $this->testResults['checker_2']['1 error']; + TestChecker2::setTestResult($expected_results_2, ReadinessCheckEvent::class); + $this->container->get('module_installer')->install([ + 'auto_updates', + 'auto_updates_test', + 'auto_updates_test2', + ]); + // Check for message on 'admin/structure' instead of the status report + // because checkers will be run if needed on the status report. + $this->drupalGet('admin/structure'); + $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]); + $assert->pageTextContainsOnce($expected_results_2[0]->getMessages()[0]); + + // Confirm that when on of the module is uninstalled the other module's + // checker result is still displayed. + $this->container->get('module_installer')->uninstall(['auto_updates_test2']); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]); + $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]); + + // Confirm that when on of the module is uninstalled the other module's + // checker result is still displayed. + $this->container->get('module_installer')->uninstall(['auto_updates_test']); + $this->drupalGet('admin/structure'); + $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]); + $assert->pageTextNotContains($expected_results_1[0]->getMessages()[0]); + } + + /** + * Asserts that the readiness requirement displays no errors or warnings. + * + * @param bool $run_link + * (optional) Whether there should be a link to run the readiness checks. + * Defaults to FALSE. + */ + private function assertNoErrors(bool $run_link = FALSE): void { + $this->assertRequirement('checked', 'Your site is ready for automatic updates.', [], $run_link); + } + + /** + * Asserts that the displayed readiness requirement contains warnings. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The readiness check results that should be visible. + * @param bool $run_link + * (optional) Whether there should be a link to run the readiness checks. + * Defaults to FALSE. + */ + private function assertWarnings(array $expected_results, bool $run_link = FALSE): void { + $this->assertRequirement('warning', static::$warningsExplanation, $expected_results, $run_link); + } + + /** + * Asserts that the displayed readiness requirement contains errors. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The readiness check results that should be visible. + * @param bool $run_link + * (optional) Whether there should be a link to run the readiness checks. + * Defaults to FALSE. + */ + private function assertErrors(array $expected_results, bool $run_link = FALSE): void { + $this->assertRequirement('error', static::$errorsExplanation, $expected_results, $run_link); + } + + /** + * Asserts that the readiness requirement is correct. + * + * @param string $section + * The section of the status report in which the requirement is expected to + * be. Can be one of 'error', 'warning', 'checked', or 'ok'. + * @param string $preamble + * The text that should appear before the result messages. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected readiness check results, in the order we expect them to be + * displayed. + * @param bool $run_link + * (optional) Whether there should be a link to run the readiness checks. + * Defaults to FALSE. + * + * @see \Drupal\Core\Render\Element\StatusReport::getInfo() + */ + private function assertRequirement(string $section, string $preamble, array $expected_results, bool $run_link = FALSE): void { + // Get the meaty part of the requirement element, and ensure that it begins + // with the preamble, if any. + $requirement = $this->assertSession() + ->elementExists('css', "h3#$section ~ details.system-status-report__entry:contains('Update readiness checks') .system-status-report__entry__value"); + + if ($preamble) { + $this->assertStringStartsWith($preamble, $requirement->getText()); + } + + // Convert the expected results into strings. + $expected_messages = []; + foreach ($expected_results as $result) { + $messages = $result->getMessages(); + if (count($messages) > 1) { + $expected_messages[] = $result->getSummary(); + } + $expected_messages = array_merge($expected_messages, $messages); + } + $expected_messages = array_map('strval', $expected_messages); + + // The results should appear in the given order. + $this->assertSame($expected_messages, $this->getMessagesFromRequirement($requirement)); + // Check for the presence or absence of a link to run the checks. + $this->assertSame($run_link, $requirement->hasLink('Run readiness checks')); + } + + /** + * Extracts the readiness result messages from the requirement element. + * + * @param \Behat\Mink\Element\NodeElement $requirement + * The page element containing the readiness check results. + * + * @return string[] + * The readiness result messages (including summaries), in the order they + * appear on the page. + */ + private function getMessagesFromRequirement(NodeElement $requirement): array { + $messages = []; + + // Each list item will either contain a simple string (for results with only + // one message), or a details element with a series of messages. + $items = $requirement->findAll('css', 'li'); + foreach ($items as $item) { + $details = $item->find('css', 'details'); + + if ($details) { + $messages[] = $details->find('css', 'summary')->getText(); + $messages = array_merge($messages, $this->getMessagesFromRequirement($details)); + } + else { + $messages[] = $item->getText(); + } + } + return array_unique($messages); + } + + /** + * Delays the request for the test. + * + * @param int $minutes + * The number of minutes to delay request time. Defaults to 61 minutes. + */ + private function delayRequestTime(int $minutes = 61): void { + static $total_delay = 0; + $total_delay += $minutes; + TestTime::setFakeTimeByOffset("+$total_delay minutes"); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..df9157dd2672b8683e61d4bcddb6c96ec27ae526 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Drupal\Tests\auto_updates\Functional; + +/** + * Tests that only one Automatic Update operation can be performed at a time. + * + * @group auto_updates + */ +class UpdateLockTest extends AutoUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml'); + $this->drupalLogin($this->rootUser); + $this->checkForUpdates(); + } + + /** + * Tests that only user who started an update can continue through it. + */ + public function testLock() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + $permissions = ['administer software updates']; + $user_1 = $this->createUser($permissions); + $user_2 = $this->createUser($permissions); + + // We should be able to get partway through an update without issue. + $this->drupalLogin($user_1); + $this->drupalGet('/admin/modules/automatic-update'); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $this->assertUpdateReady(); + $assert_session->buttonExists('Continue'); + $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH); + + // Another user cannot show up and try to start an update, since the other + // user already started one. + $this->drupalLogin($user_2); + $this->drupalGet('/admin/modules/automatic-update'); + $assert_session->buttonNotExists('Update'); + $assert_session->pageTextContains('Cannot begin an update because another Composer operation is currently in progress.'); + + // If the current user did not start the update, they should not be able to + // continue it, either. + $this->drupalGet($url); + $assert_session->pageTextContains('Cannot continue the update because another Composer operation is currently in progress.'); + $assert_session->buttonNotExists('Continue'); + + // The user who started the update should be able to continue it. + $this->drupalLogin($user_1); + $this->drupalGet($url); + $assert_session->pageTextNotContains('Cannot continue the update because another Composer operation is currently in progress.'); + $assert_session->buttonExists('Continue'); + } + +} diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0a942b4ececfa451858cadf12da4bddd94f8d6d3 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php @@ -0,0 +1,292 @@ +<?php + +namespace Drupal\Tests\auto_updates\Functional; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\Exception\UpdateException; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\auto_updates_test\ReadinessChecker\TestChecker1; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; + +/** + * @covers \Drupal\auto_updates\Form\UpdaterForm + * + * @group auto_updates + */ +class UpdaterFormTest extends AutoUpdatesFunctionalTestBase { + + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'auto_updates', + 'auto_updates_test', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); + $this->drupalLogin($this->rootUser); + $this->checkForUpdates(); + } + + /** + * Data provider for URLs to the update form. + * + * @return string[][] + * Test case parameters. + */ + public function providerUpdateFormReferringUrl(): array { + return [ + 'Modules page' => ['/admin/modules/automatic-update'], + 'Reports page' => ['/admin/reports/updates/automatic-update'], + ]; + } + + /** + * Data provider for testTableLooksCorrect(). + * + * @return string[][] + * Test case parameters. + */ + public function providerTableLooksCorrect(): array { + return [ + 'Modules page' => ['modules'], + 'Reports page' => ['reports'], + ]; + } + + /** + * Tests that the form doesn't display any buttons if Drupal is up-to-date. + * + * @todo Mark this test as skipped if the web server is PHP's built-in, single + * threaded server. + * + * @param string $update_form_url + * The URL of the update form to visit. + * + * @dataProvider providerUpdateFormReferringUrl + */ + public function testFormNotDisplayedIfAlreadyCurrent(string $update_form_url): void { + $this->setCoreVersion('9.8.1'); + $this->checkForUpdates(); + + $this->drupalGet($update_form_url); + + $assert_session = $this->assertSession(); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains('No update available'); + $assert_session->buttonNotExists('Update'); + } + + /** + * Tests that available updates are rendered correctly in a table. + * + * @param string $access_page + * The page from which the update form should be visited. + * Can be one of 'modules' to visit via the module list, or 'reports' to + * visit via the administrative reports page. + * + * @dataProvider providerTableLooksCorrect + */ + public function testTableLooksCorrect(string $access_page): void { + $this->drupalPlaceBlock('local_tasks_block', ['primary' => TRUE]); + $assert_session = $this->assertSession(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + // Navigate to the automatic updates form. + $this->drupalGet('/admin'); + if ($access_page === 'modules') { + $this->clickLink('Extend'); + $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.'); + } + else { + $this->clickLink('Reports'); + $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.'); + $this->clickLink('Available updates'); + } + $this->clickLink('Update'); + $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); + $cells = $assert_session->elementExists('css', '#edit-projects .update-update-security') + ->findAll('css', 'td'); + $this->assertCount(3, $cells); + $assert_session->elementExists('named', ['link', 'Drupal'], $cells[0]); + $this->assertSame('9.8.0', $cells[1]->getText()); + $this->assertSame('9.8.1 (Release notes)', $cells[2]->getText()); + $release_notes = $assert_session->elementExists('named', ['link', 'Release notes'], $cells[2]); + $this->assertSame('Release notes for Drupal', $release_notes->getAttribute('title')); + $assert_session->buttonExists('Update'); + $this->assertUpdateStagedTimes(0); + } + + /** + * Tests handling of errors and warnings during the update process. + */ + public function testUpdateErrors(): void { + $session = $this->getSession(); + $assert_session = $this->assertSession(); + $page = $session->getPage(); + + // Store a fake readiness error, which will be cached. + $message = t("You've not experienced Shakespeare until you have read him in the original Klingon."); + $error = ValidationResult::createError([$message]); + TestChecker1::setTestResult([$error], ReadinessCheckEvent::class); + + $this->drupalGet('/admin/reports/status'); + $page->clickLink('Run readiness checks'); + $assert_session->pageTextContainsOnce((string) $message); + // Ensure that the fake error is cached. + $session->reload(); + $assert_session->pageTextContainsOnce((string) $message); + + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + // Set up a new fake error. + $this->createTestValidationResults(); + $expected_results = $this->testResults['checker_1']['1 error']; + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + + // If a validator raises an error during readiness checking, the form should + // not have a submit button. + $this->drupalGet('/admin/modules/automatic-update'); + $assert_session->buttonNotExists('Update'); + // Since this is an administrative page, the error message should be visible + // thanks to auto_updates_page_top(). The readiness checks were re-run + // during the form build, which means the new error should be cached and + // displayed instead of the previously cached error. + $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]); + $assert_session->pageTextContainsOnce(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $assert_session->pageTextNotContains((string) $message); + TestChecker1::setTestResult(NULL, ReadinessCheckEvent::class); + + // Repackage the validation error as an exception, so we can test what + // happens if a validator throws once the update has started. + $error = new UpdateException($expected_results, 'The update exploded.'); + TestChecker1::setException($error, PreCreateEvent::class); + $session->reload(); + $assert_session->pageTextNotContains(static::$errorsExplanation); + $assert_session->pageTextNotContains(static::$warningsExplanation); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + + // If a validator flags an error, but doesn't throw, the update should still + // be halted. + $this->assertUpdateStagedTimes(0); + $assert_session->pageTextContainsOnce('An error has occurred.'); + $page->clickLink('the error page'); + $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]); + // Since there's only one error message, we shouldn't see the summary... + $assert_session->pageTextNotContains($expected_results[0]->getSummary()); + // ...but we should see the exception message. + $assert_session->pageTextContainsOnce('The update exploded.'); + // If the error is thrown in PreCreateEvent the update stage will not have + // been created. + $assert_session->buttonNotExists('Delete existing update'); + TestChecker1::setTestResult($expected_results, PreCreateEvent::class); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(0); + $assert_session->pageTextContainsOnce('An error has occurred.'); + $page->clickLink('the error page'); + // Since there's only one message, we shouldn't see the summary. + $assert_session->pageTextNotContains($expected_results[0]->getSummary()); + $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]); + } + + /** + * Tests that updating to a different minor version isn't supported. + * + * @param string $update_form_url + * The URL of the update form to visit. + * + * @dataProvider providerUpdateFormReferringUrl + */ + public function testMinorVersionUpdateNotSupported(string $update_form_url): void { + $this->setCoreVersion('9.7.1'); + $this->checkForUpdates(); + + $this->drupalGet($update_form_url); + + $assert_session = $this->assertSession(); + $assert_session->pageTextContainsOnce('Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.'); + $assert_session->buttonNotExists('Update'); + } + + /** + * 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() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $this->drupalGet('/admin/modules/automatic-update'); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $this->assertUpdateStagedTimes(1); + + // Confirm we are on the confirmation page. + $this->assertUpdateReady(); + $assert_session->buttonExists('Continue'); + + // Return to the start 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'); + + // Delete the existing update. + $page->pressButton('Delete existing update'); + $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.'); + + // 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->assertUpdateStagedTimes(2); + $assert_session->buttonExists('Continue'); + } + + /** + * Asserts the number of times an update was staged. + * + * @param int $attempted_times + * The expected number of times an update was staged. + */ + private function assertUpdateStagedTimes(int $attempted_times): void { + /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $beginner */ + $beginner = $this->container->get('package_manager.beginner'); + $this->assertCount($attempted_times, $beginner->getInvocationArguments()); + + /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */ + $stager = $this->container->get('package_manager.stager'); + $this->assertCount($attempted_times, $stager->getInvocationArguments()); + + /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $committer */ + $committer = $this->container->get('package_manager.committer'); + $this->assertEmpty($committer->getInvocationArguments()); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..f5a80df5bd3964fddfc76dff100b95933fae34fa --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php @@ -0,0 +1,126 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\auto_updates\Traits\ValidationTestTrait; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; + +/** + * Base class for kernel tests of the Automatic Updates module. + */ +abstract class AutoUpdatesKernelTestBase extends KernelTestBase { + + use ValidationTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'update', 'update_test']; + + /** + * The mocked HTTP client that returns metadata about available updates. + * + * We need to preserve this as a class property so that we can re-inject it + * into the container when a rebuild is triggered by module installation. + * + * @var \GuzzleHttp\Client + * + * @see ::register() + */ + private $client; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // The Update module's default configuration must be installed for our + // fake release metadata to be fetched. + $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); + + // By default, pretend we're running Drupal core 9.8.0 and a non-security + // update to 9.8.1 is available. + $this->setCoreVersion('9.8.0'); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml'); + + // Set a last cron run time so that the cron frequency validator will run + // from a sane state. + // @see \Drupal\auto_updates\Validator\CronFrequencyValidator + $this->container->get('state')->set('system.cron_last', time()); + } + + /** + * Sets the current (running) version of core, as known to the Update module. + * + * @param string $version + * The current version of core. + */ + protected function setCoreVersion(string $version): void { + $this->config('update_test.settings') + ->set('system_info.#all.version', $version) + ->save(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + // If we previously set up a mock HTTP client in ::setReleaseMetadata(), + // re-inject it into the container. + if ($this->client) { + $container->set('http_client', $this->client); + } + + $this->disableValidators($container); + } + + /** + * Disables any validators that will interfere with this test. + */ + protected function disableValidators(ContainerBuilder $container): void { + // Disable the filesystem permissions validator, since we cannot guarantee + // that the current code base will be writable in all testing situations. + // We test this validator functionally in our build tests, since those do + // give us control over the filesystem permissions. + // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + $container->removeDefinition('auto_updates.validator.file_system_permissions'); + $container->removeDefinition('package_manager.validator.file_system'); + } + + /** + * Sets the release metadata file to use when fetching available updates. + * + * @param string $file + * The path of the XML metadata file to use. + */ + protected function setReleaseMetadata(string $file): void { + $metadata = Utils::tryFopen($file, 'r'); + $response = new Response(200, [], Utils::streamFor($metadata)); + $handler = new MockHandler([$response]); + $this->client = new Client([ + 'handler' => HandlerStack::create($handler), + ]); + $this->container->set('http_client', $this->client); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2d3dc10143fb2c1340170ee5d7b63711030e3c9f --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php @@ -0,0 +1,117 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\auto_updates\CronUpdater; +use Drupal\Core\Form\FormState; +use Drupal\package_manager\ComposerUtility; +use Drupal\update\UpdateSettingsForm; + +/** + * @covers \Drupal\auto_updates\CronUpdater + * @covers \auto_updates_form_update_settings_alter + * + * @group auto_updates + */ +class CronUpdaterTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + ]; + + /** + * Data provider for ::testUpdaterCalled(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerUpdaterCalled(): array { + $fixture_dir = __DIR__ . '/../../fixtures/release-history'; + + return [ + 'disabled, normal release' => [ + CronUpdater::DISABLED, + "$fixture_dir/drupal.9.8.1.xml", + FALSE, + ], + 'disabled, security release' => [ + CronUpdater::DISABLED, + "$fixture_dir/drupal.9.8.1-security.xml", + FALSE, + ], + 'security only, security release' => [ + CronUpdater::SECURITY, + "$fixture_dir/drupal.9.8.1-security.xml", + TRUE, + ], + 'security only, normal release' => [ + CronUpdater::SECURITY, + "$fixture_dir/drupal.9.8.1.xml", + FALSE, + ], + 'enabled, normal release' => [ + CronUpdater::ALL, + "$fixture_dir/drupal.9.8.1.xml", + TRUE, + ], + 'enabled, security release' => [ + CronUpdater::ALL, + "$fixture_dir/drupal.9.8.1-security.xml", + TRUE, + ], + ]; + } + + /** + * Tests that the cron handler calls the updater as expected. + * + * @param string $setting + * Whether automatic updates should be enabled during cron. Possible values + * are 'disable', 'security', and 'patch'. + * @param string $release_data + * If automatic updates are enabled, the path of the fake release metadata + * that should be served when fetching information on available updates. + * @param bool $will_update + * Whether an update should be performed, given the previous two arguments. + * + * @dataProvider providerUpdaterCalled + */ + public function testUpdaterCalled(string $setting, string $release_data, bool $will_update): void { + // Our form alter does not refresh information on available updates, so + // ensure that the appropriate update data is loaded beforehand. + $this->setReleaseMetadata($release_data); + update_get_available(TRUE); + + // Submit the configuration form programmatically, to prove our alterations + // work as expected. + $form_builder = $this->container->get('form_builder'); + $form_state = new FormState(); + $form = $form_builder->buildForm(UpdateSettingsForm::class, $form_state); + // Ensure that the version ranges in the setting's description, which are + // computed dynamically, look correct. + $this->assertStringContainsString('Automatic updates are only supported for 9.8.x versions of Drupal core. Drupal 9.8 will receive security updates until 9.10.0 is released.', $form['auto_updates_cron']['#description']); + $form_state->setValue('auto_updates_cron', $setting); + $form_builder->submitForm(UpdateSettingsForm::class, $form_state); + + // Mock the updater so we can assert that its methods are called or bypassed + // depending on configuration. + $will_update = (int) $will_update; + $updater = $this->prophesize('\Drupal\auto_updates\Updater'); + + $composer = ComposerUtility::createForDirectory(__DIR__ . '/../../fixtures/fake-site'); + $updater->getActiveComposer()->willReturn($composer); + + $updater->begin(['drupal' => '9.8.1'])->shouldBeCalledTimes($will_update); + $updater->stage()->shouldBeCalledTimes($will_update); + $updater->apply()->shouldBeCalledTimes($will_update); + $updater->destroy()->shouldBeCalledTimes($will_update); + $this->container->set('auto_updates.updater', $updater->reveal()); + + $this->container->get('cron')->run(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..df8eb2932406ebe619559f43dce929e86fa9aa9c --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @covers \Drupal\auto_updates\Validator\CoreComposerValidator + * + * @group auto_updates + */ +class CoreComposerValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + ]; + + /** + * Tests that an error is raised if core is not required in composer.json. + */ + public function testCoreNotRequired(): void { + // Point to a valid composer.json with no requirements. + $active_dir = __DIR__ . '/../../../fixtures/project_staged_validation/no_core_requirements'; + $locator = $this->prophesize(PathLocator::class); + $locator->getActiveDirectory()->willReturn($active_dir); + $locator->getProjectRoot()->willReturn($active_dir); + $locator->getVendorDirectory()->willReturn($active_dir . '/vendor'); + $this->container->set('package_manager.path_locator', $locator->reveal()); + + $error = ValidationResult::createError([ + 'Drupal core does not appear to be required in the project-level composer.json.', + ]); + $this->assertCheckerResultsFromManager([$error], TRUE); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..942bd897d265765fe12564382b7a9ed74436ad00 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php @@ -0,0 +1,167 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\auto_updates\CronUpdater; +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates\Validator\CronFrequencyValidator; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use PHPUnit\Framework\AssertionFailedError; + +/** + * @covers \Drupal\auto_updates\Validator\CronFrequencyValidator + * + * @group auto_updates + */ +class CronFrequencyValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * Tests that nothing is validated if updates are disabled during cron. + */ + public function testNoValidationIfCronDisabled(): void { + $this->config('auto_updates.settings') + ->set('cron', CronUpdater::DISABLED) + ->save(); + + $validator = new class ( + $this->container->get('config.factory'), + $this->container->get('module_handler'), + $this->container->get('state'), + $this->container->get('datetime.time'), + $this->container->get('string_translation') + ) extends CronFrequencyValidator { + + /** + * {@inheritdoc} + */ + protected function validateAutomatedCron(ReadinessCheckEvent $event): void { + throw new AssertionFailedError(__METHOD__ . '() should not have been called.'); + } + + /** + * {@inheritdoc} + */ + protected function validateLastCronRun(ReadinessCheckEvent $event): void { + throw new AssertionFailedError(__METHOD__ . '() should not have been called.'); + } + + }; + $this->container->set('auto_updates.cron_frequency_validator', $validator); + $this->assertCheckerResultsFromManager([], TRUE); + } + + /** + * Data provider for ::testLastCronRunValidation(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerLastCronRunValidation(): array { + $error = ValidationResult::createError([ + 'Cron has not run recently. For more information, see the online handbook entry for <a href="https://www.drupal.org/cron">configuring cron jobs</a> to run at least every 3 hours.', + ]); + + return [ + 'cron never ran' => [ + 0, + [$error], + ], + 'cron ran four hours ago' => [ + time() - 14400, + [$error], + ], + 'cron ran an hour ago' => [ + time() - 3600, + [], + ], + ]; + } + + /** + * Tests validation based on the last cron run time. + * + * @param int $last_run + * A timestamp of the last time cron ran. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerLastCronRunValidation + */ + public function testLastCronRunValidation(int $last_run, array $expected_results): void { + $this->container->get('state')->set('system.cron_last', $last_run); + $this->assertCheckerResultsFromManager($expected_results, TRUE); + + // After running cron, any errors or warnings should be gone. + $this->container->get('cron')->run(); + $this->assertCheckerResultsFromManager([], TRUE); + } + + /** + * Data provider for ::testAutomatedCronValidation(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerAutomatedCronValidation(): array { + return [ + 'default configuration' => [ + NULL, + [], + ], + 'every 6 hours' => [ + 21600, + [ + ValidationResult::createWarning([ + 'Cron is not set to run frequently enough. <a href="/admin/config/system/cron">Configure it</a> to run at least every 3 hours or disable automated cron and run it via an external scheduling system.', + ]), + ], + ], + 'every 25 hours' => [ + 90000, + [ + ValidationResult::createError([ + 'Cron is not set to run frequently enough. <a href="/admin/config/system/cron">Configure it</a> to run at least every 3 hours or disable automated cron and run it via an external scheduling system.', + ]), + ], + ], + ]; + } + + /** + * Tests validation based on Automated Cron settings. + * + * @param int|null $interval + * The configured interval for Automated Cron. If NULL, the default value + * will be used. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerAutomatedCronValidation + */ + public function testAutomatedCronValidation(?int $interval, array $expected_results): void { + $this->enableModules(['automated_cron']); + $this->installConfig('automated_cron'); + + if (isset($interval)) { + $this->config('automated_cron.settings') + ->set('interval', $interval) + ->save(); + } + $this->assertCheckerResultsFromManager($expected_results, TRUE); + + // Even after running cron, we should have the same results. + $this->container->get('cron')->run(); + $this->assertCheckerResultsFromManager($expected_results); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2a385f1775129f695b0488ff13ea706633945ffe --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use Prophecy\Argument; + +/** + * Tests that Package Manager validators are invoked during readiness checking. + * + * @group auto_updates + * + * @covers \Drupal\auto_updates\Validator\PackageManagerReadinessCheck + * + * @see \Drupal\Tests\package_manager\Kernel\ComposerExecutableValidatorTest + * @see \Drupal\Tests\package_manager\Kernel\DiskSpaceValidatorTest + * @see \Drupal\Tests\package_manager\Kernel\PendingUpdatesValidatorTest + * @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest + */ +class PackageManagerReadinessChecksTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + ]; + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + // No need to disable any validators in this test. + } + + /** + * Data provider for ::testValidatorInvoked(). + * + * @return string[][] + * Sets of arguments to pass to the test method. + */ + public function providerValidatorInvoked(): array { + return [ + ['package_manager.validator.composer_executable'], + ['package_manager.validator.disk_space'], + ['package_manager.validator.pending_updates'], + ['package_manager.validator.file_system'], + ]; + } + + /** + * Tests that a Package Manager validator is invoked during readiness checks. + * + * @param string $service_id + * The service ID of the validator that should be invoked. + * + * @dataProvider providerValidatorInvoked + */ + public function testValidatorInvoked(string $service_id): void { + // Set up a mocked version of the Composer executable validator, to prove + // that it gets called with a readiness check event, when we run readiness + // checks. + $event = Argument::type(ReadinessCheckEvent::class); + $validator = $this->prophesize(PreOperationStageValidatorInterface::class); + $validator->validateStagePreOperation($event)->shouldBeCalled(); + $this->container->set($service_id, $validator->reveal()); + + $this->container->get('auto_updates.readiness_validation_manager') + ->run(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f7a9a29f836415e289815403e1411a24a3a0234d --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php @@ -0,0 +1,213 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\auto_updates\Event\ReadinessCheckEvent; +use Drupal\auto_updates_test\ReadinessChecker\TestChecker1; +use Drupal\auto_updates_test2\ReadinessChecker\TestChecker2; +use Drupal\system\SystemManager; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @coversDefaultClass \Drupal\auto_updates\Validation\ReadinessValidationManager + * + * @group auto_updates + */ +class ReadinessValidationManagerTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates_test', + 'package_manager', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + $this->installSchema('user', ['users_data']); + $this->createTestValidationResults(); + } + + /** + * @covers ::getResults + */ + public function testGetResults(): void { + $this->enableModules(['auto_updates', 'auto_updates_test2']); + $this->assertCheckerResultsFromManager([], TRUE); + + $expected_results = [ + array_pop($this->testResults['checker_1']), + array_pop($this->testResults['checker_2']), + ]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class); + $expected_results_all = array_merge($expected_results[0], $expected_results[1]); + $this->assertCheckerResultsFromManager($expected_results_all, TRUE); + + // Define a constant flag that will cause the readiness checker + // service priority to be altered. + define('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY', 1); + // Rebuild the container to trigger the service to be altered. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + // Confirm that results will be NULL if the run() is not called again + // because the readiness checker services order has been altered. + $this->assertNull($this->getResultsFromManager()); + // Confirm that after calling run() the expected results order has changed. + $expected_results_all_reversed = array_reverse($expected_results_all); + $this->assertCheckerResultsFromManager($expected_results_all_reversed, TRUE); + + $expected_results = [ + $this->testResults['checker_1']['2 errors 2 warnings'], + $this->testResults['checker_2']['2 errors 2 warnings'], + ]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class); + $expected_results_all = array_merge($expected_results[1], $expected_results[0]); + $this->assertCheckerResultsFromManager($expected_results_all, TRUE); + + // Confirm that filtering by severity works. + $warnings_only_results = [ + $expected_results[1]['2:warnings'], + $expected_results[0]['1:warnings'], + ]; + $this->assertCheckerResultsFromManager($warnings_only_results, FALSE, SystemManager::REQUIREMENT_WARNING); + + $errors_only_results = [ + $expected_results[1]['2:errors'], + $expected_results[0]['1:errors'], + ]; + $this->assertCheckerResultsFromManager($errors_only_results, FALSE, SystemManager::REQUIREMENT_ERROR); + } + + /** + * Tests that the manager is run after modules are installed. + */ + public function testRunOnInstall(): void { + $expected_results = [array_pop($this->testResults['checker_1'])]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + // Confirm that messages from an existing module are displayed when + // 'auto_updates' is installed. + $this->container->get('module_installer')->install(['auto_updates']); + $this->assertCheckerResultsFromManager($expected_results[0]); + + // Confirm that the checkers are run when a module that provides a readiness + // checker is installed. + $expected_results = [ + array_pop($this->testResults['checker_1']), + array_pop($this->testResults['checker_2']), + ]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates_test2']); + $expected_results_all = array_merge($expected_results[0], $expected_results[1]); + $this->assertCheckerResultsFromManager($expected_results_all); + + // Confirm that the checkers are not run when a module that does not provide + // a readiness checker is installed. + $unexpected_results = [ + array_pop($this->testResults['checker_1']), + array_pop($this->testResults['checker_2']), + ]; + TestChecker1::setTestResult($unexpected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult($unexpected_results[1], ReadinessCheckEvent::class); + $this->container->get('module_installer')->install(['help']); + $this->assertCheckerResultsFromManager($expected_results_all); + } + + /** + * Tests that the manager is run after modules are uninstalled. + */ + public function testRunOnUninstall(): void { + $expected_results = [ + array_pop($this->testResults['checker_1']), + array_pop($this->testResults['checker_2']), + ]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class); + // Confirm that messages from existing modules are displayed when + // 'auto_updates' is installed. + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2', 'help']); + $expected_results_all = array_merge($expected_results[0], $expected_results[1]); + $this->assertCheckerResultsFromManager($expected_results_all); + + // Confirm that the checkers are run when a module that provides a readiness + // checker is uninstalled. + $expected_results = [ + array_pop($this->testResults['checker_1']), + ]; + TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class); + TestChecker2::setTestResult(array_pop($this->testResults['checker_2']), ReadinessCheckEvent::class); + $this->container->get('module_installer')->uninstall(['auto_updates_test2']); + $this->assertCheckerResultsFromManager($expected_results[0]); + + // Confirm that the checkers are not run when a module that does provide a + // readiness checker is uninstalled. + $unexpected_results = [ + array_pop($this->testResults['checker_1']), + ]; + TestChecker1::setTestResult($unexpected_results[0], ReadinessCheckEvent::class); + $this->container->get('module_installer')->uninstall(['help']); + $this->assertCheckerResultsFromManager($expected_results[0]); + } + + /** + * @covers ::runIfNoStoredResults + */ + public function testRunIfNeeded(): void { + $expected_results = array_pop($this->testResults['checker_1']); + TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class); + $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2']); + $this->assertCheckerResultsFromManager($expected_results); + + $unexpected_results = array_pop($this->testResults['checker_1']); + TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class); + $manager = $this->container->get('auto_updates.readiness_validation_manager'); + // Confirm that the new results will not be returned because the checkers + // will not be run. + $manager->runIfNoStoredResults(); + $this->assertCheckerResultsFromManager($expected_results); + + // Confirm that the new results will be returned because the checkers will + // be run if the stored results are deleted. + /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ + $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates'); + $key_value->delete('readiness_validation_last_run'); + $expected_results = $unexpected_results; + $manager->runIfNoStoredResults(); + $this->assertCheckerResultsFromManager($expected_results); + + // Confirm that the results are the same after rebuilding the container. + $unexpected_results = array_pop($this->testResults['checker_1']); + TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class); + /** @var \Drupal\Core\DrupalKernel $kernel */ + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $this->assertCheckerResultsFromManager($expected_results); + + // Define a constant flag that will cause the readiness checker + // service priority to be altered. This will cause the priority of + // 'auto_updates_test.checker' to change from 2 to 4 which will be now + // higher than 'auto_updates_test2.checker' which has a priority of 3. + // Because the list of checker IDs is not identical to the previous checker + // run runIfNoStoredValidResults() will run the checkers again. + define('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY', 1); + + // Rebuild the container to trigger the readiness checker services to be + // reordered. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $expected_results = $unexpected_results; + /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $manager */ + $manager = $this->container->get('auto_updates.readiness_validation_manager'); + $manager->runIfNoStoredResults(); + $this->assertCheckerResultsFromManager($expected_results); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7c07a2b9731eacfe0196b8757c50c9f8e3f6dcb7 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php @@ -0,0 +1,247 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\auto_updates\Updater; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; +use org\bovigo\vfs\vfsStream; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\auto_updates\Validator\StagedProjectsValidator + * + * @group auto_updates + */ +class StagedProjectsValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + $container->getDefinition('auto_updates.updater') + ->setClass(TestUpdater::class); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // This test deals with fake sites that don't necessarily have lock files, + // so disable lock file validation. + $container->removeDefinition('package_manager.validator.lock_file'); + } + + /** + * Runs the validator under test against an arbitrary pair of directories. + * + * @param string $active_dir + * The active directory to validate. + * @param string $stage_dir + * The stage directory to validate. + * + * @return \Drupal\package_manager\ValidationResult[] + * The validation results. + */ + private function validate(string $active_dir, string $stage_dir): array { + $locator = $this->prophesize(PathLocator::class); + + $locator->getActiveDirectory()->willReturn($active_dir); + $locator->getProjectRoot()->willReturn($active_dir); + $locator->getVendorDirectory()->willReturn($active_dir); + + $stage_dir_exists = is_dir($stage_dir); + if ($stage_dir_exists) { + // If we are testing a fixture with existing stage directory then we + // need to use a virtual file system directory, so we can create a + // subdirectory using the stage ID after it is created below. + $vendor = vfsStream::newDirectory('au_stage'); + $this->vfsRoot->addChild($vendor); + TestUpdater::$stagingRoot = $vendor->url(); + } + else { + // If we are testing non-existent staging directory we can use the path + // directly. + TestUpdater::$stagingRoot = $stage_dir; + } + + $this->container->set('package_manager.path_locator', $locator->reveal()); + + $updater = $this->container->get('auto_updates.updater'); + $stage_id = $updater->begin(['drupal' => '9.8.1']); + if ($stage_dir_exists) { + // Copy the fixture's staging directory into a subdirectory using the + // stage ID as the directory name. + $sub_directory = vfsStream::newDirectory($stage_id); + $vendor->addChild($sub_directory); + (new Filesystem())->mirror($stage_dir, $sub_directory->url()); + } + + // The staged projects validator only runs before staged updates are + // applied. Since the active and stage directories may not exist, we don't + // want to invoke the other stages of the update because they may raise + // errors that are outside of the scope of what we're testing here. + try { + $updater->apply(); + return []; + } + catch (StageValidationException $e) { + return $e->getResults(); + } + } + + /** + * Tests that if an exception is thrown, the event will absorb it. + */ + public function testEventConsumesExceptionResults(): void { + // Prepare a fake site in the virtual file system which contains valid + // Composer data. + $fixture = __DIR__ . '/../../../fixtures/fake-site'; + copy("$fixture/composer.json", 'public://composer.json'); + copy("$fixture/composer.lock", 'public://composer.lock'); + + $event_dispatcher = $this->container->get('event_dispatcher'); + // Disable the disk space validator, since it doesn't work with vfsStream. + $disk_space_validator = $this->container->get('package_manager.validator.disk_space'); + $event_dispatcher->removeSubscriber($disk_space_validator); + // Just before the staged changes are applied, delete the composer.json file + // to trigger an error. This uses the highest possible priority to guarantee + // it runs before any other subscribers. + $listener = function () { + unlink('public://composer.json'); + }; + $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX); + + $results = $this->validate('public://', '/fake/stage/dir'); + $this->assertCount(1, $results); + $messages = reset($results)->getMessages(); + $this->assertCount(1, $messages); + $this->assertStringContainsString('Composer could not find the config file: public:///composer.json', (string) reset($messages)); + } + + /** + * Tests validations errors. + * + * @param string $fixtures_dir + * The fixtures directory that provides the active and staged composer.lock + * files. + * @param string $expected_summary + * The expected error summary. + * @param string[] $expected_messages + * The expected error messages. + * + * @dataProvider providerErrors + */ + public function testErrors(string $fixtures_dir, string $expected_summary, array $expected_messages): void { + $this->assertNotEmpty($fixtures_dir); + $this->assertDirectoryExists($fixtures_dir); + + $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged"); + $this->assertCount(1, $results); + $result = array_pop($results); + $this->assertSame($expected_summary, (string) $result->getSummary()); + $actual_messages = $result->getMessages(); + $this->assertCount(count($expected_messages), $actual_messages); + foreach ($expected_messages as $message) { + $actual_message = array_shift($actual_messages); + $this->assertSame($message, (string) $actual_message); + } + } + + /** + * Data provider for testErrors(). + * + * @return \string[][] + * Test cases for testErrors(). + */ + public function providerErrors(): array { + $fixtures_folder = realpath(__DIR__ . '/../../../fixtures/project_staged_validation'); + return [ + 'new_project_added' => [ + "$fixtures_folder/new_project_added", + 'The update cannot proceed because the following Drupal projects were installed during the update.', + [ + "module 'drupal/test_module2' installed.", + "custom module 'drupal/dev-test_module2' installed.", + ], + ], + 'project_removed' => [ + "$fixtures_folder/project_removed", + 'The update cannot proceed because the following Drupal projects were removed during the update.', + [ + "theme 'drupal/test_theme' removed.", + "custom theme 'drupal/dev-test_theme' removed.", + ], + ], + 'version_changed' => [ + "$fixtures_folder/version_changed", + 'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.', + [ + "module 'drupal/test_module' from 1.3.0 to 1.3.1.", + "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.", + ], + ], + ]; + } + + /** + * Tests validation when there are no errors. + */ + public function testNoErrors(): void { + $fixtures_dir = realpath(__DIR__ . '/../../../fixtures/project_staged_validation/no_errors'); + $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged"); + $this->assertIsArray($results); + $this->assertEmpty($results); + } + + /** + * Tests validation when a composer.lock file is not found. + */ + public function testNoLockFile(): void { + $fixtures_dir = realpath(__DIR__ . '/../../../fixtures/project_staged_validation/no_errors'); + + $results = $this->validate("$fixtures_dir/active", $fixtures_dir); + $this->assertCount(1, $results); + $result = array_pop($results); + $this->assertSame("No lockfile found. Unable to read locked packages", (string) $result->getMessages()[0]); + $this->assertSame('', (string) $result->getSummary()); + } + +} + +/** + * A test-only version of the updater. + */ +class TestUpdater extends Updater { + + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + protected static function getStagingRoot(): string { + return static::$stagingRoot ?: parent::getStagingRoot(); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8c51c425cf02fd7b4617dfb91f7abc3481ad80f8 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php @@ -0,0 +1,71 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation; + +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase; + +/** + * @covers \Drupal\auto_updates\Validator\UpdateVersionValidator + * + * @group auto_updates + */ +class UpdateVersionValidatorTest extends AutoUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'package_manager', + ]; + + /** + * Tests an update version that is same major & minor version as the current. + */ + public function testNoMajorOrMinorUpdates(): void { + $this->assertCheckerResultsFromManager([], TRUE); + } + + /** + * Tests an update version that is a different major version than the current. + */ + public function testMajorUpdates(): void { + $this->setCoreVersion('8.9.1'); + $result = ValidationResult::createError([ + 'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.1, because automatic updates from one major version to another are not supported.', + ]); + $this->assertCheckerResultsFromManager([$result], TRUE); + + } + + /** + * Tests an update version that is a different minor version than the current. + */ + public function testMinorUpdates(): void { + $this->setCoreVersion('9.7.1'); + $result = ValidationResult::createError([ + 'Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.', + ]); + $this->assertCheckerResultsFromManager([$result], TRUE); + } + + /** + * Tests an update version that is a lower version than the current. + */ + public function testDowngrading(): void { + $this->setCoreVersion('9.8.2'); + $result = ValidationResult::createError(['Update version 9.8.1 is lower than 9.8.2, downgrading is not supported.']); + $this->assertCheckerResultsFromManager([$result], TRUE); + } + + /** + * Tests a current version that is a dev version. + */ + public function testUpdatesFromDevVersion(): void { + $this->setCoreVersion('9.8.0-dev'); + $result = ValidationResult::createError(['Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.1, because automatic updates from a dev version to any other version are not supported.']); + $this->assertCheckerResultsFromManager([$result], TRUE); + } + +} diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3741a174499754bba6e5898de317112d5b243bef --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php @@ -0,0 +1,47 @@ +<?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.1', $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.1'); + + $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 new file mode 100644 index 0000000000000000000000000000000000000000..ad7e46193b85721aad286baeb854e1185cdef083 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Drupal\Tests\auto_updates\Kernel; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * @coversDefaultClass \Drupal\auto_updates\Updater + * + * @group auto_updates + */ +class UpdaterTest extends AutoUpdatesKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'auto_updates', + 'auto_updates_test', + 'package_manager', + 'package_manager_bypass', + 'system', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * Tests that correct versions are staged after calling ::begin(). + */ + public function testCorrectVersionsStaged() { + $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. + $user = $this->createUser([], NULL, TRUE, ['uid' => 2]); + $this->setCurrentUser($user); + + // Point to a fake site which requires Drupal core via a distribution. The + // lock file should be scanned to determine the core packages, which should + // result in drupal/core-recommended being updated. + $fixture_dir = __DIR__ . '/../../fixtures/fake-site'; + $locator = $this->prophesize(PathLocator::class); + $locator->getActiveDirectory()->willReturn($fixture_dir); + $locator->getProjectRoot()->willReturn($fixture_dir); + $locator->getVendorDirectory()->willReturn($fixture_dir); + $this->container->set('package_manager.path_locator', $locator->reveal()); + + $id = $this->container->get('auto_updates.updater')->begin([ + 'drupal' => '9.8.1', + ]); + // Rebuild the container to ensure the project versions are kept in state. + /** @var \Drupal\Core\DrupalKernel $kernel */ + $kernel = $this->container->get('kernel'); + $kernel->rebuildContainer(); + $this->container = $kernel->getContainer(); + // Keep using the mocked path locator and current user. + $this->container->set('package_manager.path_locator', $locator->reveal()); + $this->setCurrentUser($user); + + // When we call Updater::stage(), the stored project versions should be + // read from state and passed to Composer Stager's Stager service, in the + // form of a Composer command. This is done using package_manager_bypass's + // invocation recorder, rather than a regular mock, in order to test that + // the invocation recorder itself works. + // The production dependencies should be updated first... + $expected_require_arguments = [ + 'require', + 'drupal/core-recommended:9.8.1', + '--update-with-all-dependencies', + ]; + // ...followed by the dev dependencies. + $expected_require_dev_arguments = [ + 'require', + 'drupal/core-dev:9.8.1', + '--update-with-all-dependencies', + '--dev', + ]; + $this->container->get('auto_updates.updater')->claim($id)->stage(); + + /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */ + $stager = $this->container->get('package_manager.stager'); + [ + $actual_require_arguments, + $actual_require_dev_arguments, + ] = $stager->getInvocationArguments(); + $this->assertSame($expected_require_arguments, $actual_require_arguments[0]); + $this->assertSame($expected_require_dev_arguments, $actual_require_dev_arguments[0]); + } + + /** + * @covers ::begin + * + * @dataProvider providerInvalidProjectVersions + */ + public function testInvalidProjectVersions(array $project_versions): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Currently only updates to Drupal core are supported.'); + $this->container->get('auto_updates.updater')->begin($project_versions); + } + + /** + * Data provider for testInvalidProjectVersions(). + * + * @return array + * The test cases for testInvalidProjectVersions(). + */ + public function providerInvalidProjectVersions(): array { + return [ + 'only not drupal' => [['not_drupal' => '1.1.3']], + 'not drupal and drupal' => [['drupal' => '9.8.0', 'not_drupal' => '1.2.3']], + 'empty' => [[]], + ]; + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..3c89c720bbe6c5e4f830abd82d5420505ef81270 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php @@ -0,0 +1,41 @@ +<?php + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\Component\Serialization\Json; +use PHPUnit\Framework\Assert; + +/** + * Provides assertive methods to read and write JSON data in files. + */ +trait JsonTrait { + + /** + * Reads JSON data from a file and returns it as an array. + * + * @param string $path + * The path of the file to read. + * + * @return mixed[] + * The parsed data in the file. + */ + protected function readJson(string $path): array { + Assert::assertIsReadable($path); + $data = file_get_contents($path); + return Json::decode($data); + } + + /** + * Writes an array of data to a file as JSON. + * + * @param string $path + * The path of the file to write. + * @param array $data + * The data to be written. + */ + protected function writeJson(string $path, array $data): void { + Assert::assertIsWritable(file_exists($path) ? $path : dirname($path)); + file_put_contents($path, Json::encode($data)); + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..6b2d0407270ef675ae4c97354b62b9bf9d8305a8 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php @@ -0,0 +1,163 @@ +<?php + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\NestedArray; +use PHPUnit\Framework\Assert; +use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; + +/** + * Provides methods for interacting with installed Composer packages. + */ +trait LocalPackagesTrait { + + use JsonTrait; + + /** + * The paths of temporary copies of packages. + * + * @see ::copyPackage() + * @see ::deleteCopiedPackages() + * + * @var string[] + */ + private $copiedPackages = []; + + /** + * Returns the path of an installed package, relative to composer.json. + * + * @param array $package + * The package information, as read from the lock file. + * + * @return string + * The path of the installed package, relative to composer.json. + */ + protected function getPackagePath(array $package): string { + return 'vendor' . DIRECTORY_SEPARATOR . $package['name']; + } + + /** + * Deletes all copied packages. + * + * @see ::copyPackage() + */ + protected function deleteCopiedPackages(): void { + (new SymfonyFilesystem())->remove($this->copiedPackages); + } + + /** + * Copies a package's entire directory to another location. + * + * The copies' paths will be stored so that they can be easily deleted by + * ::deleteCopiedPackages(). + * + * @param string $source_dir + * The path of the package directory to copy. + * @param string|null $destination_dir + * (optional) The directory to which the package should be copied. Will be + * suffixed with a random string to ensure uniqueness. If not given, the + * system temporary directory will be used. + * + * @return string + * The path of the temporary copy. + * + * @see ::deleteCopiedPackages() + */ + protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { + Assert::assertDirectoryExists($source_dir); + + if (empty($destination_dir)) { + $destination_dir = FileSystem::getOsTemporaryDirectory(); + Assert::assertNotEmpty($destination_dir); + $destination_dir .= DIRECTORY_SEPARATOR; + } + $destination_dir = uniqid($destination_dir); + Assert::assertDirectoryDoesNotExist($destination_dir); + + (new SymfonyFilesystem())->mirror($source_dir, $destination_dir); + array_push($this->copiedPackages, $destination_dir); + + return $destination_dir; + } + + /** + * Generates local path repositories for a set of installed packages. + * + * @param string $dir + * The directory which contains composer.lock. + * + * @return mixed[][] + * The local path repositories' configuration, for inclusion in a + * composer.json file. + */ + protected function getLocalPackageRepositories(string $dir): array { + $repositories = []; + + foreach ($this->getPackagesFromLockFile($dir) as $package) { + // Ensure the package directory is writable, since we'll need to make a + // few changes to it. + $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package); + Assert::assertIsWritable($path); + $composer = $path . DIRECTORY_SEPARATOR . 'composer.json'; + + // Overwrite the composer.json with the fully resolved package information + // from the lock file. + // @todo Back up composer.json before overwriting it? + $this->writeJson($composer, $package); + + $name = $package['name']; + $repositories[$name] = $this->createPathRepository($path); + } + return $repositories; + } + + /** + * Defines a local path repository for a given path. + * + * @param string $path + * The path of the repository. + * + * @return array + * The local path repository definition. + */ + protected function createPathRepository(string $path): array { + return [ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ]; + } + + /** + * Alters a package's composer.json file. + * + * @param string $package_dir + * The package directory. + * @param array $changes + * The changes to merge into composer.json. + */ + protected function alterPackage(string $package_dir, array $changes): void { + $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json'; + $data = $this->readJson($composer); + $data = NestedArray::mergeDeep($data, $changes); + $this->writeJson($composer, $data); + } + + /** + * Reads all package information from a composer.lock file. + * + * @param string $dir + * The directory which contains the lock file. + * + * @return mixed[][] + * All package information (including dev packages) from the lock file. + */ + private function getPackagesFromLockFile(string $dir): array { + $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock'); + return array_merge($lock['packages'], $lock['packages-dev']); + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..f1c1919b9b2806d35fbf1a215b12c3bf45a1e15c --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php @@ -0,0 +1,57 @@ +<?php + +namespace Drupal\Tests\auto_updates\Traits; + +use PHPUnit\Framework\Assert; + +/** + * Provides methods for manipulating site settings. + */ +trait SettingsTrait { + + /** + * Appends some PHP code to settings.php. + * + * @param string $php + * The PHP code to append to settings.php. + * @param string $drupal_root + * The path of the Drupal root. + * @param string $site + * (optional) The name of the site whose settings.php should be amended. + * Defaults to 'default'. + */ + protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void { + $settings = $this->makeSettingsWritable($drupal_root, $site); + $settings = fopen($settings, 'a'); + Assert::assertIsResource($settings); + Assert::assertIsInt(fwrite($settings, $php)); + Assert::assertTrue(fclose($settings)); + } + + /** + * Ensures that settings.php is writable. + * + * @param string $drupal_root + * The path of the Drupal root. + * @param string $site + * (optional) The name of the site whose settings should be made writable. + * Defaults to 'default'. + * + * @return string + * The path to settings.php for the specified site. + */ + private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string { + $settings = implode(DIRECTORY_SEPARATOR, [ + $drupal_root, + 'sites', + $site, + 'settings.php', + ]); + chmod(dirname($settings), 0744); + chmod($settings, 0744); + Assert::assertIsWritable($settings); + + return $settings; + } + +} diff --git a/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..d287aa8d80e376cc69f85a48582514b0f22cce91 --- /dev/null +++ b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php @@ -0,0 +1,131 @@ +<?php + +namespace Drupal\Tests\auto_updates\Traits; + +use Drupal\package_manager\ValidationResult; + +use Drupal\Tests\package_manager\Traits\ValidationTestTrait as PackageManagerValidationTestTrait; + +/** + * Common methods for testing validation. + */ +trait ValidationTestTrait { + + use PackageManagerValidationTestTrait; + + /** + * Expected explanation text when readiness checkers return error messages. + * + * @var string + */ + protected static $errorsExplanation = 'Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.'; + + /** + * Expected explanation text when readiness checkers return warning messages. + * + * @var string + */ + protected static $warningsExplanation = 'Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.'; + + /** + * Test validation results. + * + * @var \Drupal\package_manager\ValidationResult[][][] + */ + protected $testResults; + + /** + * Creates ValidationResult objects to be used in tests. + */ + protected function createTestValidationResults(): void { + // Set up various validation results for the test checkers. + foreach ([1, 2] as $listener_number) { + // Set test validation results. + $this->testResults["checker_$listener_number"]['1 error'] = [ + ValidationResult::createError( + [t("$listener_number:OMG 🚒. Your server is on 🔥!")], + t("$listener_number:Summary: 🔥") + ), + ]; + $this->testResults["checker_$listener_number"]['1 error 1 warning'] = [ + "$listener_number:error" => ValidationResult::createError( + [t("$listener_number:OMG 🔌. Some one unplugged the server! How is this site even running?")], + t("$listener_number:Summary: 🔥") + ), + "$listener_number:warning" => ValidationResult::createWarning( + [t("$listener_number:It looks like it going to rain and your server is outside.")], + t("$listener_number:Warnings summary not displayed because only 1 warning message.") + ), + ]; + $this->testResults["checker_$listener_number"]['2 errors 2 warnings'] = [ + "$listener_number:errors" => ValidationResult::createError( + [ + t("$listener_number:😬Your server is in a cloud, a literal cloud!â˜ï¸."), + t("$listener_number:😂PHP only has 32k memory."), + ], + t("$listener_number:Errors summary displayed because more than 1 error message") + ), + "$listener_number:warnings" => ValidationResult::createWarning( + [ + t("$listener_number:Your server is a smart fridge. Will this work?"), + t("$listener_number:Your server case is duct tape!"), + ], + t("$listener_number:Warnings summary displayed because more than 1 warning message.") + ), + + ]; + $this->testResults["checker_$listener_number"]['2 warnings'] = [ + ValidationResult::createWarning( + [ + t("$listener_number:The universe could collapse in on itself in the next second, in which case automatic updates will not run."), + t("$listener_number:An asteroid could hit your server farm, which would also stop automatic updates from running."), + ], + t("$listener_number:Warnings summary displayed because more than 1 warning message.") + ), + ]; + $this->testResults["checker_$listener_number"]['1 warning'] = [ + ValidationResult::createWarning( + [t("$listener_number:This is your one and only warning. You have been warned.")], + t("$listener_number:No need for this summary with only 1 warning.") + ), + ]; + } + } + + /** + * Gets the messages of a particular type from the manager. + * + * @param bool $call_run + * Whether to run the checkers. + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + * + * @return \Drupal\package_manager\ValidationResult[]|null + * The messages of the type. + */ + protected function getResultsFromManager(bool $call_run = FALSE, ?int $severity = NULL): ?array { + $manager = $this->container->get('auto_updates.readiness_validation_manager'); + if ($call_run) { + $manager->run(); + } + return $manager->getResults($severity); + } + + /** + * Asserts expected validation results from the manager. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected results. + * @param bool $call_run + * (Optional) Whether to call ::run() on the manager. Defaults to FALSE. + * @param int|null $severity + * (optional) The severity for the results to return. Should be one of the + * SystemManager::REQUIREMENT_* constants. + */ + protected function assertCheckerResultsFromManager(array $expected_results, bool $call_run = FALSE, ?int $severity = NULL): void { + $actual_results = $this->getResultsFromManager($call_run, $severity); + $this->assertValidationResultsEqual($expected_results, $actual_results); + } + +}