From fcbb50e6ead496cb8b706e55d37a9e733a1d6a09 Mon Sep 17 00:00:00 2001
From: phenaproxima <phenaproxima@205645.no-reply.drupal.org>
Date: Fri, 27 Aug 2021 18:36:36 +0000
Subject: [PATCH] Issue #3230024 by phenaproxima, tedbow: Collect exclusions
 from event objects

---
 automatic_updates.services.yml                |   7 +-
 src/Event/ExcludedPathsSubscriber.php         | 104 +++++++++++++++++
 src/Event/ExcludedPathsTrait.php              |  39 +++++++
 src/Event/PreCommitEvent.php                  |  12 ++
 src/Event/PreStartEvent.php                   |  12 ++
 src/Form/UpdateFormBase.php                   | 105 ------------------
 src/Form/UpdateReady.php                      |  25 ++---
 src/Form/UpdaterForm.php                      |  23 ++--
 src/Updater.php                               | 100 ++++++-----------
 .../AutomaticUpdatesTestServiceProvider.php   |  29 -----
 .../src/ReadinessChecker/TestChecker1.php     |   9 +-
 .../src/TestUpdater.php                       |  36 ------
 .../composer_stager_bypass.info.yml           |   6 +
 .../composer_stager_bypass.services.yml       |   9 ++
 .../composer_stager_bypass/src/Beginner.php   |  19 ++++
 .../composer_stager_bypass/src/Committer.php  |  43 +++++++
 .../ComposerStagerBypassServiceProvider.php   |  25 +++++
 .../composer_stager_bypass/src/Stager.php     |  19 ++++
 tests/src/Functional/ExclusionsTest.php       |   8 +-
 tests/src/Functional/UpdaterFormTest.php      |  93 ++++++++--------
 20 files changed, 406 insertions(+), 317 deletions(-)
 create mode 100644 src/Event/ExcludedPathsSubscriber.php
 create mode 100644 src/Event/ExcludedPathsTrait.php
 create mode 100644 src/Event/PreCommitEvent.php
 create mode 100644 src/Event/PreStartEvent.php
 delete mode 100644 src/Form/UpdateFormBase.php
 delete mode 100644 tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php
 delete mode 100644 tests/modules/automatic_updates_test/src/TestUpdater.php
 create mode 100644 tests/modules/composer_stager_bypass/composer_stager_bypass.info.yml
 create mode 100644 tests/modules/composer_stager_bypass/composer_stager_bypass.services.yml
 create mode 100644 tests/modules/composer_stager_bypass/src/Beginner.php
 create mode 100644 tests/modules/composer_stager_bypass/src/Committer.php
 create mode 100644 tests/modules/composer_stager_bypass/src/ComposerStagerBypassServiceProvider.php
 create mode 100644 tests/modules/composer_stager_bypass/src/Stager.php

diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index d1ab120bd3..2b05d22d68 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 0000000000..6665a5378d
--- /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 0000000000..15443cff82
--- /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 0000000000..1f9ed03833
--- /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 0000000000..e8fecadb02
--- /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 c5485b4096..0000000000
--- 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 f4dcc94b9e..68e792b163 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 98d57ed320..320f6ebc63 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 65f908bbdf..b26e60e34e 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 5817b28556..0000000000
--- 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 ada93a7ed5..1294649269 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 14cd9c06fc..0000000000
--- 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 0000000000..11449912c0
--- /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 0000000000..6e1695752f
--- /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 0000000000..aa91dc187a
--- /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 0000000000..af67de0613
--- /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 0000000000..2c8ae8f764
--- /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 0000000000..22a042fb39
--- /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 8d0832eb8c..5f3f8ede1b 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 2354113d17..287d258961 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();
   }
 
   /**
-- 
GitLab