diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index d1ab120bd3d65f49a3552744fc6948217a9bd1ae..2b05d22d688e59e6f76b3c4e79e46a62383e916d 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -4,7 +4,7 @@ services: arguments: ['@keyvalue.expirable', '@datetime.time', 24] automatic_updates.updater: class: Drupal\automatic_updates\Updater - arguments: ['@state', '@string_translation','@automatic_updates.beginner', '@automatic_updates.stager', '@automatic_updates.cleaner', '@automatic_updates.committer' , '@file_system', '@event_dispatcher', '%app.root%', '%site.path%'] + arguments: ['@state', '@string_translation','@automatic_updates.beginner', '@automatic_updates.stager', '@automatic_updates.cleaner', '@automatic_updates.committer', '@event_dispatcher'] automatic_updates.staged_package_validator: class: Drupal\automatic_updates\Validation\StagedProjectsValidation arguments: ['@string_translation', '@automatic_updates.updater' ] @@ -70,3 +70,8 @@ services: ['@automatic_updates.symfony_exec_finder' ] automatic_updates.process_factory: class: Drupal\automatic_updates\ComposerStager\ProcessFactory + automatic_updates.excluded_paths_subscriber: + class: Drupal\automatic_updates\Event\ExcludedPathsSubscriber + arguments: ['%app.root%', '%site.path%', '@file_system'] + tags: + - { name: event_subscriber } diff --git a/src/Event/ExcludedPathsSubscriber.php b/src/Event/ExcludedPathsSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..6665a5378ddd0f3ac9be5342b36e1f811bcfba55 --- /dev/null +++ b/src/Event/ExcludedPathsSubscriber.php @@ -0,0 +1,104 @@ +<?php + +namespace Drupal\automatic_updates\Event; + +use Drupal\automatic_updates\AutomaticUpdatesEvents; +use Drupal\Core\File\FileSystemInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber to exclude certain paths from update operations. + */ +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; + + /** + * Constructs an UpdateSubscriber. + * + * @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. + */ + public function __construct(string $app_root, string $site_path, FileSystemInterface $file_system) { + $this->appRoot = $app_root; + $this->sitePath = $site_path; + $this->fileSystem = $file_system; + } + + /** + * Reacts to the beginning of an update process. + * + * @param \Drupal\automatic_updates\Event\PreStartEvent $event + * The event object. + */ + public function preStart(PreStartEvent $event): void { + if ($public = $this->fileSystem->realpath('public://')) { + $event->excludePath($public); + } + if ($private = $this->fileSystem->realpath('private://')) { + $event->excludePath($private); + } + // If this module is a git clone, exclude it. + if (is_dir(__DIR__ . '/../../.git')) { + $event->excludePath($this->fileSystem->realpath(__DIR__ . '/../..')); + } + + // Exclude site-specific settings files. + $settings_files = [ + 'settings.php', + 'settings.local.php', + 'services.yml', + ]; + foreach ($settings_files as $settings_file) { + $file_path = implode(DIRECTORY_SEPARATOR, [ + $this->appRoot, + $this->sitePath, + $settings_file, + ]); + $file_path = $this->fileSystem->realpath($file_path); + if (file_exists($file_path)) { + $event->excludePath($file_path); + } + + $default_file_path = implode(DIRECTORY_SEPARATOR, [ + 'sites', + 'default', + $settings_file, + ]); + $event->excludePath($default_file_path); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + AutomaticUpdatesEvents::PRE_START => 'preStart', + ]; + } + +} diff --git a/src/Event/ExcludedPathsTrait.php b/src/Event/ExcludedPathsTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..15443cff82b7ceecfde18d85bd9fabe2e2fb6e7c --- /dev/null +++ b/src/Event/ExcludedPathsTrait.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\automatic_updates\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 update 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 update operation. + * + * @return string[] + * The paths to exclude. + */ + public function getExcludedPaths(): array { + return array_unique($this->excludedPaths); + } + +} diff --git a/src/Event/PreCommitEvent.php b/src/Event/PreCommitEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..1f9ed03833a984dd041d9a6ebca79ac3bdc885c4 --- /dev/null +++ b/src/Event/PreCommitEvent.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\automatic_updates\Event; + +/** + * Event fired before staged changes are copied into the active site. + */ +class PreCommitEvent extends UpdateEvent { + + use ExcludedPathsTrait; + +} diff --git a/src/Event/PreStartEvent.php b/src/Event/PreStartEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..e8fecadb02c07647debaecdfce62fd0d2b556dfc --- /dev/null +++ b/src/Event/PreStartEvent.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\automatic_updates\Event; + +/** + * Event fired before an update begins. + */ +class PreStartEvent extends UpdateEvent { + + use ExcludedPathsTrait; + +} diff --git a/src/Form/UpdateFormBase.php b/src/Form/UpdateFormBase.php deleted file mode 100644 index c5485b4096bc623b1d8870533fdd63b4fe23b82d..0000000000000000000000000000000000000000 --- a/src/Form/UpdateFormBase.php +++ /dev/null @@ -1,105 +0,0 @@ -<?php - -namespace Drupal\automatic_updates\Form; - -use Drupal\automatic_updates\Exception\UpdateException; -use Drupal\automatic_updates\Updater; -use Drupal\automatic_updates\Validation\ValidationResult; -use Drupal\Core\Form\FormBase; -use Drupal\Core\Form\FormStateInterface; - -/** - * Defines a base class for forms which are part of the attended update process. - */ -abstract class UpdateFormBase extends FormBase { - - /** - * The updater service. - * - * @var \Drupal\automatic_updates\Updater - */ - protected $updater; - - /** - * Constructs an UpdateFormBase object. - * - * @param \Drupal\automatic_updates\Updater $updater - * The updater service. - */ - public function __construct(Updater $updater) { - $this->updater = $updater; - } - - /** - * Fires an update validation event and handles any detected errors. - * - * If $form and $form_state are passed, errors will be flagged against the - * form_id element, since it's guaranteed to exist in all forms. Otherwise, - * the errors will be displayed in the messages area. - * - * @param string $event - * The name of the event to fire. Should be one of the constants from - * \Drupal\automatic_updates\AutomaticUpdatesEvents. - * @param array|null $form - * (optional) The complete form array. - * @param \Drupal\Core\Form\FormStateInterface|null $form_state - * (optional) The current form state. - * - * @return bool - * TRUE if no errors were found, FALSE otherwise. - */ - protected function validateUpdate(string $event, array &$form = NULL, FormStateInterface $form_state = NULL): bool { - $errors = FALSE; - foreach ($this->getValidationErrors($event) as $error) { - if ($form && $form_state) { - $form_state->setError($form['form_id'], $error); - } - else { - $this->messenger()->addError($error); - } - $errors = TRUE; - } - return !$errors; - } - - /** - * Fires an update validation event and returns all resulting errors. - * - * @param string $event - * The name of the event to fire. Should be one of the constants from - * \Drupal\automatic_updates\AutomaticUpdatesEvents. - * - * @return \Drupal\Component\Render\MarkupInterface[] - * The validation errors, if any. - */ - protected function getValidationErrors(string $event): array { - $errors = []; - try { - $this->updater->dispatchUpdateEvent($event); - } - catch (UpdateException $e) { - foreach ($e->getValidationResults() as $result) { - $errors = array_merge($errors, $this->getMessagesFromValidationResult($result)); - } - } - return $errors; - } - - /** - * Extracts all relevant messages from an update validation result. - * - * @param \Drupal\automatic_updates\Validation\ValidationResult $result - * The validation result. - * - * @return \Drupal\Component\Render\MarkupInterface[] - * The messages to display from the validation result. - */ - protected function getMessagesFromValidationResult(ValidationResult $result): array { - $messages = $result->getMessages(); - if (count($messages) > 1) { - array_unshift($messages, $result->getSummary()); - } - return $messages; - } - -} diff --git a/src/Form/UpdateReady.php b/src/Form/UpdateReady.php index f4dcc94b9e45a28ee2f8c214e58452b38ab9181a..68e792b1638dffb06e2c4ef31660fd871b998b66 100644 --- a/src/Form/UpdateReady.php +++ b/src/Form/UpdateReady.php @@ -2,10 +2,10 @@ namespace Drupal\automatic_updates\Form; -use Drupal\automatic_updates\AutomaticUpdatesEvents; use Drupal\automatic_updates\BatchProcessor; use Drupal\automatic_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; @@ -17,7 +17,14 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * @internal * Form classes are internal. */ -class UpdateReady extends UpdateFormBase { +class UpdateReady extends FormBase { + + /** + * The updater service. + * + * @var \Drupal\automatic_updates\Updater + */ + protected $updater; /** * The state service. @@ -37,7 +44,7 @@ class UpdateReady extends UpdateFormBase { * The state service. */ public function __construct(Updater $updater, MessengerInterface $messenger, StateInterface $state) { - parent::__construct($updater); + $this->updater = $updater; $this->setMessenger($messenger); $this->state = $state; } @@ -64,10 +71,6 @@ class UpdateReady extends UpdateFormBase { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { - if (!$this->validateUpdate(AutomaticUpdatesEvents::PRE_COMMIT)) { - return $form; - } - $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']), @@ -89,14 +92,6 @@ class UpdateReady extends UpdateFormBase { return $form; } - /** - * {@inheritdoc} - */ - public function validateForm(array &$form, FormStateInterface $form_state) { - parent::validateForm($form, $form_state); - $this->validateUpdate(AutomaticUpdatesEvents::PRE_COMMIT, $form, $form_state); - } - /** * {@inheritdoc} */ diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php index 98d57ed320dd9bddcc2ec1a471b1e7d86c6033af..320f6ebc63da69fe5dd0cd31da95b7dc1f6e1038 100644 --- a/src/Form/UpdaterForm.php +++ b/src/Form/UpdaterForm.php @@ -2,12 +2,12 @@ namespace Drupal\automatic_updates\Form; -use Drupal\automatic_updates\AutomaticUpdatesEvents; use Drupal\automatic_updates\BatchProcessor; use Drupal\automatic_updates\Updater; use Drupal\automatic_updates_9_3_shim\ProjectRelease; use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Link; use Drupal\Core\State\StateInterface; @@ -21,7 +21,14 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * @internal * Form classes are internal. */ -class UpdaterForm extends UpdateFormBase { +class UpdaterForm extends FormBase { + + /** + * The updater service. + * + * @var \Drupal\automatic_updates\Updater + */ + protected $updater; /** * The module handler. @@ -38,7 +45,7 @@ class UpdaterForm extends UpdateFormBase { protected $state; /** - * Constructs a new UpdateManagerUpdate object. + * Constructs a new UpdaterForm object. * * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler. @@ -48,7 +55,7 @@ class UpdaterForm extends UpdateFormBase { * The updater service. */ public function __construct(ModuleHandlerInterface $module_handler, StateInterface $state, Updater $updater) { - parent::__construct($updater); + $this->updater = $updater; $this->moduleHandler = $module_handler; $this->state = $state; } @@ -213,14 +220,6 @@ class UpdaterForm extends UpdateFormBase { return $actions; } - /** - * {@inheritdoc} - */ - public function validateForm(array &$form, FormStateInterface $form_state) { - parent::validateForm($form, $form_state); - $this->validateUpdate(AutomaticUpdatesEvents::PRE_START, $form, $form_state); - } - /** * Submit function to delete an existing in-progress update. */ diff --git a/src/Updater.php b/src/Updater.php index 65f908bbdfcb40dfac9190dca1884f061c622752..b26e60e34ebfa7a82dfb7c8e3fc55da4dc035a53 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -3,9 +3,10 @@ namespace Drupal\automatic_updates; use Composer\Autoload\ClassLoader; +use Drupal\automatic_updates\Event\PreCommitEvent; +use Drupal\automatic_updates\Event\PreStartEvent; use Drupal\automatic_updates\Event\UpdateEvent; use Drupal\automatic_updates\Exception\UpdateException; -use Drupal\Core\File\FileSystemInterface; use Drupal\Core\State\StateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; @@ -23,6 +24,16 @@ class Updater { use StringTranslationTrait; + /** + * The event classes to dispatch for various update events. + * + * @var string[] + */ + protected const EVENT_CLASSES = [ + AutomaticUpdatesEvents::PRE_START => PreStartEvent::class, + AutomaticUpdatesEvents::PRE_COMMIT => PreCommitEvent::class, + ]; + /** * The state key in which to store the status of the update. * @@ -65,13 +76,6 @@ class Updater { */ protected $state; - /** - * The file system service. - * - * @var \Drupal\Core\File\FileSystemInterface - */ - protected $fileSystem; - /** * The event dispatcher service. * @@ -79,20 +83,6 @@ class Updater { */ protected $eventDispatcher; - /** - * The Drupal root. - * - * @var string - */ - protected $appRoot; - - /** - * The current site directory, relative to the Drupal root. - * - * @var string - */ - protected $sitePath; - /** * Constructs an Updater object. * @@ -108,26 +98,17 @@ class Updater { * The Composer Stager's cleaner service. * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer * The Composer Stager's committer service. - * @param \Drupal\Core\File\FileSystemInterface $file_system - * The file system service. * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. - * @param string $app_root - * The Drupal root. - * @param string $site_path - * The current site directory, relative to the Drupal root. */ - public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, string $app_root, string $site_path) { + public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, EventDispatcherInterface $event_dispatcher) { $this->state = $state; $this->beginner = $beginner; $this->stager = $stager; $this->cleaner = $cleaner; $this->committer = $committer; $this->setStringTranslation($translation); - $this->fileSystem = $file_system; $this->eventDispatcher = $event_dispatcher; - $this->appRoot = $app_root; - $this->sitePath = $site_path; } /** @@ -183,51 +164,25 @@ class Updater { */ public function begin(): string { $stage_key = $this->createActiveStage(); - $this->beginner->begin(static::getActiveDirectory(), static::getStageDirectory(), NULL, 120, $this->getExclusions()); + $event = $this->dispatchUpdateEvent(AutomaticUpdatesEvents::PRE_START); + $this->beginner->begin(static::getActiveDirectory(), static::getStageDirectory(), NULL, 120, $this->getExclusions($event)); return $stage_key; } /** - * Gets the paths that should be excluded from the staging area. + * Gets the excluded paths collected by an event object. + * + * @param \Drupal\automatic_updates\Event\PreStartEvent|\Drupal\automatic_updates\Event\PreCommitEvent $event + * The event object. * * @return string[] * The paths to exclude, relative to the active directory. */ - protected function getExclusions(): array { - $exclusions = []; - if ($public = $this->fileSystem->realpath('public://')) { - $exclusions[] = $public; - } - if ($private = $this->fileSystem->realpath('private://')) { - $exclusions[] = $private; - } - // If this module is a git clone, exclude it. - if (is_dir(__DIR__ . '/../.git')) { - $exclusions[] = $this->fileSystem->realpath(__DIR__ . '/..'); - } - - // Exclude site-specific settings files. - $settings_files = [ - 'settings.php', - 'settings.local.php', - 'services.yml', - ]; - foreach ($settings_files as $settings_file) { - $file_path = implode(DIRECTORY_SEPARATOR, [ - $this->appRoot, - $this->sitePath, - $settings_file, - ]); - $file_path = $this->fileSystem->realpath($file_path); - if (file_exists($file_path)) { - $exclusions[] = $file_path; - } - } - + private function getExclusions($event): array { $make_relative = function (string $path): string { return str_replace($this->getActiveDirectory() . '/', '', $path); }; - return array_map($make_relative, $exclusions); + return array_map($make_relative, $event->getExcludedPaths()); } /** @@ -272,6 +227,10 @@ class Updater { * Commits the current update. */ public function commit(): void { + $this->dispatchUpdateEvent(AutomaticUpdatesEvents::PRE_COMMIT); + // @todo Pass excluded paths into the committer once + // https://github.com/php-tuf/composer-stager/pull/14 is in a tagged release + // of Composer Stager. $this->committer->commit($this->getStageDirectory(), static::getActiveDirectory()); } @@ -316,16 +275,21 @@ class Updater { * @param string $event_name * The name of the event to dispatch. * + * @return \Drupal\automatic_updates\Event\UpdateEvent + * The event object. + * * @throws \Drupal\automatic_updates\Exception\UpdateException * If any of the event subscribers adds a validation error. */ - public function dispatchUpdateEvent(string $event_name): void { - $event = new UpdateEvent(); + public function dispatchUpdateEvent(string $event_name): UpdateEvent { + $class = static::EVENT_CLASSES[$event_name] ?? UpdateEvent::class; + $event = new $class(); $this->eventDispatcher->dispatch($event, $event_name); if ($checker_results = $event->getResults(SystemManager::REQUIREMENT_ERROR)) { throw new UpdateException($checker_results, "Unable to complete the update because of errors."); } + return $event; } } diff --git a/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php b/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php deleted file mode 100644 index 5817b28556a7bc59396414882ee0695f9a9d061a..0000000000000000000000000000000000000000 --- a/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php - -namespace Drupal\automatic_updates_test; - -use Drupal\Core\DependencyInjection\ContainerBuilder; -use Drupal\Core\DependencyInjection\ServiceProviderBase; - -/** - * Defines a service provider for testing automatic updates. - */ -class AutomaticUpdatesTestServiceProvider extends ServiceProviderBase { - - /** - * {@inheritdoc} - */ - public function alter(ContainerBuilder $container) { - parent::alter($container); - - $modules = $container->getParameter('container.modules'); - if (isset($modules['automatic_updates'])) { - // Swap in our special updater implementation, which can be rigged to - // throw errors during various points in the update process in order to - // test error handling during updates. - $container->getDefinition('automatic_updates.updater') - ->setClass(TestUpdater::class); - } - } - -} diff --git a/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php b/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php index ada93a7ed5b8fb1542a95fb6fb81dcdab66484c8..1294649269ea4c727009cf9feff90db10758482b 100644 --- a/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php +++ b/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php @@ -40,13 +40,13 @@ class TestChecker1 implements EventSubscriberInterface { * This method is static to enable setting the expected messages before the * test module is enabled. * - * @param \Drupal\automatic_updates\Validation\ValidationResult[] $checker_results - * The test validation result. + * @param \Drupal\automatic_updates\Validation\ValidationResult[]|\Throwable $checker_results + * The test validation results, or an exception to throw. * @param string $event_name * (optional )The event name. Defaults to * AutomaticUpdatesEvents::READINESS_CHECK. */ - public static function setTestResult(array $checker_results, string $event_name = AutomaticUpdatesEvents::READINESS_CHECK): void { + public static function setTestResult($checker_results, string $event_name = AutomaticUpdatesEvents::READINESS_CHECK): void { \Drupal::state()->set(static::STATE_KEY . ".$event_name", $checker_results); } @@ -60,6 +60,9 @@ class TestChecker1 implements EventSubscriberInterface { */ protected function addResults(UpdateEvent $event, string $state_key): void { $results = $this->state->get($state_key, []); + if ($results instanceof \Throwable) { + throw $results; + } foreach ($results as $result) { $event->addValidationResult($result); } diff --git a/tests/modules/automatic_updates_test/src/TestUpdater.php b/tests/modules/automatic_updates_test/src/TestUpdater.php deleted file mode 100644 index 14cd9c06fc787c22fa0b17370b527148250cf2e0..0000000000000000000000000000000000000000 --- a/tests/modules/automatic_updates_test/src/TestUpdater.php +++ /dev/null @@ -1,36 +0,0 @@ -<?php - -namespace Drupal\automatic_updates_test; - -use Drupal\automatic_updates\Exception\UpdateException; -use Drupal\automatic_updates\Updater; - -/** - * A test-only updater which can throw errors during the update process. - */ -class TestUpdater extends Updater { - - /** - * Sets the errors to be thrown during the begin() method. - * - * @param \Drupal\automatic_updates\Validation\ValidationResult[] $errors - * The validation errors that should be thrown. - */ - public static function setBeginErrors(array $errors): void { - \Drupal::state()->set('automatic_updates_test.updater_errors', [ - 'begin' => $errors, - ]); - } - - /** - * {@inheritdoc} - */ - public function begin(): string { - $errors = $this->state->get('automatic_updates_test.updater_errors', []); - if (isset($errors['begin'])) { - throw new UpdateException($errors['begin'], reset($errors['begin'])->getSummary()); - } - return parent::begin(); - } - -} diff --git a/tests/modules/composer_stager_bypass/composer_stager_bypass.info.yml b/tests/modules/composer_stager_bypass/composer_stager_bypass.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..11449912c0d933894572cca7a703865d00dc1f7a --- /dev/null +++ b/tests/modules/composer_stager_bypass/composer_stager_bypass.info.yml @@ -0,0 +1,6 @@ +name: 'Composer Stager bypass' +description: 'Mocks Composer Stager services for functional testing' +type: module +package: Testing +dependencies: + - automatic_updates:automatic_updates diff --git a/tests/modules/composer_stager_bypass/composer_stager_bypass.services.yml b/tests/modules/composer_stager_bypass/composer_stager_bypass.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..6e1695752f72fd5a59375d007fcddb40a7fedfbc --- /dev/null +++ b/tests/modules/composer_stager_bypass/composer_stager_bypass.services.yml @@ -0,0 +1,9 @@ +services: + composer_stager_bypass.committer: + public: false + class: Drupal\composer_stager_bypass\Committer + decorates: automatic_updates.committer + arguments: + - '@composer_stager_bypass.committer.inner' + properties: + - { _serviceId: automatic_updates.committer } diff --git a/tests/modules/composer_stager_bypass/src/Beginner.php b/tests/modules/composer_stager_bypass/src/Beginner.php new file mode 100644 index 0000000000000000000000000000000000000000..aa91dc187a2405549d0146239a8ff72f797fc313 --- /dev/null +++ b/tests/modules/composer_stager_bypass/src/Beginner.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\composer_stager_bypass; + +use PhpTuf\ComposerStager\Domain\BeginnerInterface; +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; + +/** + * Defines an update beginner which doesn't do anything. + */ +class Beginner implements BeginnerInterface { + + /** + * {@inheritdoc} + */ + public function begin(string $activeDir, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + } + +} diff --git a/tests/modules/composer_stager_bypass/src/Committer.php b/tests/modules/composer_stager_bypass/src/Committer.php new file mode 100644 index 0000000000000000000000000000000000000000..af67de06135a00da66e381eb5bc455c6fc6f76a8 --- /dev/null +++ b/tests/modules/composer_stager_bypass/src/Committer.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\composer_stager_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 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, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + } + + /** + * {@inheritdoc} + */ + public function directoryExists(string $stagingDir): bool { + return $this->decorated->directoryExists($stagingDir); + } + +} diff --git a/tests/modules/composer_stager_bypass/src/ComposerStagerBypassServiceProvider.php b/tests/modules/composer_stager_bypass/src/ComposerStagerBypassServiceProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..2c8ae8f7644f749ed6ccc3dbae0b3d900e6048b6 --- /dev/null +++ b/tests/modules/composer_stager_bypass/src/ComposerStagerBypassServiceProvider.php @@ -0,0 +1,25 @@ +<?php + +namespace Drupal\composer_stager_bypass; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; + +/** + * Defines services to bypass Composer Stager's core functionality. + */ +class ComposerStagerBypassServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container) { + parent::alter($container); + + $container->getDefinition('automatic_updates.beginner') + ->setClass(Beginner::class); + $container->getDefinition('automatic_updates.stager') + ->setClass(Stager::class); + } + +} diff --git a/tests/modules/composer_stager_bypass/src/Stager.php b/tests/modules/composer_stager_bypass/src/Stager.php new file mode 100644 index 0000000000000000000000000000000000000000..22a042fb392332049c4387a138b112c488439571 --- /dev/null +++ b/tests/modules/composer_stager_bypass/src/Stager.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\composer_stager_bypass; + +use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\StagerInterface; + +/** + * Defines an update stager which doesn't actually do anything. + */ +class Stager implements StagerInterface { + + /** + * {@inheritdoc} + */ + public function stage(array $composerCommand, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + } + +} diff --git a/tests/src/Functional/ExclusionsTest.php b/tests/src/Functional/ExclusionsTest.php index 8d0832eb8c6c21866a3d2be04bc3e380785fabba..5f3f8ede1b6b50a138b95ec40414bc2755d3674e 100644 --- a/tests/src/Functional/ExclusionsTest.php +++ b/tests/src/Functional/ExclusionsTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\automatic_updates\Functional; +use Drupal\automatic_updates\Event\PreStartEvent; use Drupal\Tests\BrowserTestBase; /** @@ -51,13 +52,16 @@ class ExclusionsTest extends BrowserTestBase { * @covers \Drupal\automatic_updates\Updater::getExclusions */ public function testExclusions(): void { + $event = new PreStartEvent(); + $this->container->get('automatic_updates.excluded_paths_subscriber') + ->preStart($event); + /** @var \Drupal\automatic_updates\Updater $updater */ $updater = $this->container->get('automatic_updates.updater'); - $reflector = new \ReflectionObject($updater); $method = $reflector->getMethod('getExclusions'); $method->setAccessible(TRUE); - $exclusions = $method->invoke($updater); + $exclusions = $method->invoke($updater, $event); $this->assertContains("$this->siteDirectory/files", $exclusions); $this->assertContains("$this->siteDirectory/private", $exclusions); diff --git a/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php index 2354113d17435d786a0db4480f0f7f484aa7fc46..287d258961aff4389af28857de97a6947b98c06a 100644 --- a/tests/src/Functional/UpdaterFormTest.php +++ b/tests/src/Functional/UpdaterFormTest.php @@ -3,9 +3,8 @@ namespace Drupal\Tests\automatic_updates\Functional; use Drupal\automatic_updates\AutomaticUpdatesEvents; -use Drupal\automatic_updates\Validation\ValidationResult; +use Drupal\automatic_updates\Exception\UpdateException; use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1; -use Drupal\automatic_updates_test\TestUpdater; use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait; use Drupal\Tests\BrowserTestBase; @@ -29,6 +28,7 @@ class UpdaterFormTest extends BrowserTestBase { protected static $modules = [ 'automatic_updates', 'automatic_updates_test', + 'composer_stager_bypass', 'update_test', ]; @@ -102,65 +102,66 @@ class UpdaterFormTest extends BrowserTestBase { } /** - * Tests that the form runs update validators before starting the batch job. + * Tests handling of errors and warnings during the update process. */ - public function testValidation(): void { - $this->setCoreVersion('9.8.0'); + public function testUpdateErrors(): void { + $session = $this->getSession(); + $assert_session = $this->assertSession(); + $page = $session->getPage(); - // Ensure that one of the update validators will produce an error when we - // try to run updates. + $this->setCoreVersion('9.8.0'); $this->createTestValidationResults(); + $expected_results = $this->testResults['checker_1']['1 error']; - TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START); + // Repackage the validation error as an exception, so we can test what + // happens if a validator throws. + $error = new UpdateException($expected_results, 'The update exploded.'); + TestChecker1::setTestResult($error, AutomaticUpdatesEvents::PRE_START); $this->drupalLogin($this->rootUser); $this->checkForUpdates(); $this->drupalGet('/admin/automatic-update'); - $this->getSession()->getPage()->pressButton('Download these updates'); + $page->pressButton('Download these updates'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('An error has occurred.'); + $page->clickLink('the error page'); + $assert_session->pageTextContains((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->pageTextContains('The update exploded.'); - $assert_session = $this->assertSession(); - // We should still be on the same page, having not passed validation. - $assert_session->addressEquals('/admin/automatic-update'); - foreach ($expected_results[0]->getMessages() as $message) { - $assert_session->pageTextContains($message); - } - // Since there is only one error message, we shouldn't see the summary. + // If a validator flags an error, but doesn't throw, the update should still + // be halted. + TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START); + $this->deleteStagedUpdate(); + $page->pressButton('Download these updates'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('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->pageTextContains((string) $expected_results[0]->getMessages()[0]); - // Ensure the update-ready form runs pre-commit checks immediately, even - // before it's submitted. - $expected_results = $this->testResults['checker_1']['1 error 1 warning']; - TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_COMMIT); - $this->drupalGet('/admin/automatic-update-ready'); - $assert_session->pageTextContains($expected_results['1:error']->getMessages()[0]); - // Only show errors, not warnings. - $assert_session->pageTextNotContains($expected_results['1:warning']->getMessages()[0]); - // Since there is only one error message, we shouldn't see the summary. And - // we shouldn't see the warning's summary in any case. - $assert_session->pageTextNotContains($expected_results['1:error']->getSummary()); - $assert_session->pageTextNotContains($expected_results['1:warning']->getSummary()); + // If a validator flags a warning, but doesn't throw, the update should + // continue. + $expected_results = $this->testResults['checker_1']['1 warning']; + TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START); + $session->reload(); + $this->deleteStagedUpdate(); + $page->pressButton('Download these updates'); + $this->checkForMetaRefresh(); + $assert_session->pageTextContains('Ready to update'); } /** - * Tests that errors during the update process are displayed as messages. + * Deletes a staged, failed update. */ - public function testBatchErrorsAreForwardedToMessenger(): void { - $this->setCoreVersion('9.8.0'); - - $error = ValidationResult::createError([ - t('💥'), - ], t('The update exploded.')); - TestUpdater::setBeginErrors([$error]); - - $this->drupalLogin($this->rootUser); - $this->checkForUpdates(); - $this->drupalGet('/admin/automatic-update'); - $this->submitForm([], 'Download these updates'); - $assert_session = $this->assertSession(); - $assert_session->pageTextContains('An error has occurred.'); - $this->getSession()->getPage()->clickLink('the error page'); - $assert_session->pageTextContains('💥'); - $assert_session->pageTextContains('The update exploded.'); + private function deleteStagedUpdate(): void { + $session = $this->getSession(); + $session->getPage()->pressButton('Delete existing update'); + $this->assertSession()->pageTextContains('Staged update deleted'); + $session->reload(); } /**