From c5a246bf05a8336d4891520e5e2805c756a861cc Mon Sep 17 00:00:00 2001
From: Kunal Sachdev <57170-kunal.sachdev@users.noreply.drupalcode.org>
Date: Tue, 14 Feb 2023 18:57:55 +0000
Subject: [PATCH] Issue #3252299 by kunal.sachdev, phenaproxima, tedbow, Wim
 Leers: Reliably support cweagans/composer-patches in Package Manager &
 Automatic Updates: validate stage

---
 package_manager/package_manager.module        |  19 +-
 package_manager/package_manager.services.yml  |   2 +
 .../Validator/ComposerPatchesValidator.php    | 162 +++++++++--
 .../tests/fixtures/fake_site/composer.json    |   8 +
 .../cweagans--composer-patches/composer.json  |  13 +
 .../src/ComposerPatches.php                   |  29 ++
 .../Kernel/ComposerPatchesValidatorTest.php   | 256 +++++++++++++++---
 .../Kernel/PackageManagerKernelTestBase.php   |   6 +
 .../tests/src/Traits/ValidationTestTrait.php  |  23 +-
 .../ScaffoldFilePermissionsValidatorTest.php  |   2 +-
 10 files changed, 447 insertions(+), 73 deletions(-)
 create mode 100644 package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json
 create mode 100644 package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php

diff --git a/package_manager/package_manager.module b/package_manager/package_manager.module
index 889f3e2966..e75f4f2df2 100644
--- a/package_manager/package_manager.module
+++ b/package_manager/package_manager.module
@@ -33,7 +33,7 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) {
       $output .= '<ul>';
       $output .= '  <li>' . t('It does not support Drupal multi-site installations.') . '</li>';
       $output .= '  <li>' . t('It does not support symlinks. If you have any, see <a href="#package-manager-faq-composer-not-found">What if it says I have symlinks in my codebase?</a>.') . '</li>';
-      $output .= '  <li>' . t('It only allows supported composer plugins. If you have any, see <a href="#package-manager-faq-unsupported-composer-plugin">What if it says I have unsupported composer plugins in my codebase?</a>.') . '</li>';
+      $output .= '  <li>' . t('It only allows supported Composer plugins. If you have any, see <a href="#package-manager-faq-unsupported-composer-plugin">What if it says I have unsupported Composer plugins in my codebase?</a>.') . '</li>';
       $output .= '  <li>' . t('It does not automatically perform version control operations, e.g., with Git. Site administrators are responsible for committing updates.') . '</li>';
       $output .= '  <li>' . t('It can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>';
       $output .= '  <li>' . t('It associates the temporary copy of the site with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>';
@@ -81,11 +81,22 @@ function package_manager_help($route_name, RouteMatchInterface $route_match) {
       $output .= '<p>' . t('The new configuration will take effect on the next Composer install or update event. Do this to apply it immediately:') . '</p>';
       $output .= '<pre><code>composer install</code></pre>';
 
-      $output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . t('What if it says I have unsupported composer plugins in my codebase?') . '</h4>';
-      $output .= '<p>' . t('A fresh Drupal installation only uses supported composer plugins, but some modules or themes may depend on additional composer plugins. Please <a href=":new-issue">create a new issue</a> when you encounter this.', [
+      $output .= '<h4 id="package-manager-faq-unsupported-composer-plugin">' . t('What if it says I have unsupported Composer plugins in my codebase?') . '</h4>';
+      $output .= '<p>' . t('A fresh Drupal installation only uses supported Composer plugins, but some modules or themes may depend on additional Composer plugins. Please <a href=":new-issue">create a new issue</a> when you encounter this.', [
         ':new-issue' => 'https://www.drupal.org/node/add/project-issue/automatic_updates',
       ]) . '</p>';
-      $output .= '<p>' . t('It is possible to <em>trust</em> additional composer plugins, but this requires significant expertise: understanding the code of that composer plugin, what the effects on the file system are and how it affects the Package Manager module. Some composer plugins could result in a broken site!') . '</p>';
+      $output .= '<p>' . t('It is possible to <em>trust</em> additional Composer plugins, but this requires significant expertise: understanding the code of that Composer plugin, what the effects on the file system are and how it affects the Package Manager module. Some Composer plugins could result in a broken site!') . '</p>';
+
+      $output .= '<h4 id="package-manager-faq-composer-patches-installed-or-removed">' . t('What if it says <code>cweagans/composer-patches</code> cannot be installed/removed?') . '</h4>';
+      $output .= '<p>' . t('Installation or removal of <code>cweagans/composer-patches</code> via Package Manager is not support it. You can install or remove it manually by running Composer commands in your site root.') . '</p>';
+      $output .= '<p>' . t('To install it:') . '</p>';
+      $output .= '<pre><code>composer require cweagans/composer-patches</code></pre>';
+      $output .= '<p>' . t('To remove it:') . '</p>';
+      $output .= '<pre><code>composer remove cweagans/composer-patches</code></pre>';
+
+      $output .= '<h4 id="package-manager-faq-composer-patches-not-a-root-dependency">' . t('What if it says <code>cweagans/composer-patches</code> must be a root dependency?') . '</h4>';
+      $output .= '<p>' . t('If <code>cweagans/composer-patches</code> is installed, it must be defined as a dependency of the main project (i.e., it must be listed in the <code>require</code> or <code>require-dev</code> section of <code>composer.json</code>). You can run the following command in your site root to add it as a dependency of the main project:') . '</p>';
+      $output .= "<pre><code>composer require cweagans/composer-patches</code></pre>";
 
       $output .= '<h5>' . t('Custom code') . '</h5>';
       $output .= '<p>' . t('Symlinks are seldom truly necessary and should be avoided in your own code. No solution currently exists to get around them--they must be removed in order to use Automatic Updates.') . '</p>';
diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml
index a165aca59d..cf43ca76a2 100644
--- a/package_manager/package_manager.services.yml
+++ b/package_manager/package_manager.services.yml
@@ -240,6 +240,8 @@ services:
       - { name: event_subscriber }
   package_manager.validator.patches:
     class: Drupal\package_manager\Validator\ComposerPatchesValidator
+    arguments:
+      - '@module_handler'
     tags:
       - { name: event_subscriber }
   package_manager.validator.supported_releases:
diff --git a/package_manager/src/Validator/ComposerPatchesValidator.php b/package_manager/src/Validator/ComposerPatchesValidator.php
index 7588095ffd..efcbee12b7 100644
--- a/package_manager/src/Validator/ComposerPatchesValidator.php
+++ b/package_manager/src/Validator/ComposerPatchesValidator.php
@@ -4,7 +4,11 @@ declare(strict_types = 1);
 
 namespace Drupal\package_manager\Validator;
 
+use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Url;
+use Drupal\package_manager\ComposerUtility;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
@@ -13,30 +17,152 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
  * Validates the configuration of the cweagans/composer-patches plugin.
+ *
+ * To ensure that applied patches remain consistent between the active and
+ * stage directories, the following rules are enforced if the patcher is
+ * installed:
+ * - It must be installed in both places, or in neither of them. It can't, for
+ *   example, be installed in the active directory but not the stage directory
+ *   (or vice-versa).
+ * - It must be one of the project's direct runtime or dev dependencies.
+ * - It cannot be installed or removed by Package Manager. In other words, it
+ *   must be added to the project at the command line by someone technical
+ *   enough to install and configure it properly.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class ComposerPatchesValidator implements EventSubscriberInterface {
+final class ComposerPatchesValidator implements EventSubscriberInterface {
 
   use StringTranslationTrait;
 
   /**
-   * {@inheritdoc}
+   * The name of the plugin being analyzed.
+   *
+   * @var string
+   */
+  private const PLUGIN_NAME = 'cweagans/composer-patches';
+
+  /**
+   * The module handler service.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  private ModuleHandlerInterface $moduleHandler;
+
+  /**
+   * Constructs a ComposerPatchesValidator object.
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler service.
+   */
+  public function __construct(ModuleHandlerInterface $module_handler) {
+    $this->moduleHandler = $module_handler;
+  }
+
+  /**
+   * Validates the status of the patcher plugin.
+   *
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   The event object.
    */
-  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+  public function validatePatcher(PreOperationStageEvent $event): void {
+    $messages = [];
+
     $stage = $event->getStage();
-    $composer = $stage->getActiveComposer();
-
-    if (array_key_exists('cweagans/composer-patches', $composer->getInstalledPackages())) {
-      $composer = $composer->getComposer();
-
-      $extra = $composer->getPackage()->getExtra();
-      if (empty($extra['composer-exit-on-patch-failure'])) {
-        $event->addError([
-          $this->t('The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of @file.', [
-            '@file' => $composer->getConfig()->getConfigSource()->getName(),
-          ]),
-        ]);
+    [$plugin_installed_in_active, $is_active_root_requirement, $active_configuration_ok] = $this->computePatcherStatus($stage->getActiveComposer());
+    try {
+      [$plugin_installed_in_stage, $is_stage_root_requirement, $stage_configuration_ok] = $this->computePatcherStatus($stage->getStageComposer());
+      $has_staged_update = TRUE;
+    }
+    catch (\LogicException $e) {
+      // No staged update exists.
+      $has_staged_update = FALSE;
+    }
+
+    // If there's a staged update and the patcher has been installed or removed
+    // in the stage directory, that's a problem.
+    if ($has_staged_update && $plugin_installed_in_active !== $plugin_installed_in_stage) {
+      if ($plugin_installed_in_stage) {
+        $message = $this->t('It cannot be installed by Package Manager.');
+      }
+      else {
+        $message = $this->t('It cannot be removed by Package Manager.');
       }
+      $messages[] = $this->createErrorMessage($message, 'package-manager-faq-composer-patches-installed-or-removed');
+    }
+
+    // If the patcher is not listed in the runtime or dev dependencies, that's
+    // an error as well.
+    if (($plugin_installed_in_active && !$is_active_root_requirement) || ($has_staged_update && $plugin_installed_in_stage && !$is_stage_root_requirement)) {
+      $messages[] = $this->createErrorMessage($this->t('It must be a root dependency.'), 'package-manager-faq-composer-patches-not-a-root-dependency');
     }
+
+    // If the plugin is misconfigured in either the active or stage directories,
+    // flag an error.
+    if (($plugin_installed_in_active && !$active_configuration_ok) || ($has_staged_update && $plugin_installed_in_stage && !$stage_configuration_ok)) {
+      $messages[] = $this->t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.');
+    }
+
+    if ($messages) {
+      $summary = $this->t("Problems detected related to the Composer plugin <code>@plugin</code>.", [
+        '@plugin' => static::PLUGIN_NAME,
+      ]);
+      $event->addError($messages, $summary);
+    }
+  }
+
+  /**
+   * Appends a link to online help to an error message.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup $message
+   *   The error message.
+   * @param string $fragment
+   *   The fragment of the online help to link to.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The final, translated error message.
+   */
+  private function createErrorMessage(TranslatableMarkup $message, string $fragment): TranslatableMarkup {
+    if ($this->moduleHandler->moduleExists('help')) {
+      $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+        ->setOption('fragment', $fragment)
+        ->toString();
+
+      return $this->t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', [
+        '@message' => $message,
+        ':url' => $url,
+      ]);
+    }
+    return $message;
+  }
+
+  /**
+   * Computes the status of the patcher plugin in a particular directory.
+   *
+   * @param \Drupal\package_manager\ComposerUtility $composer
+   *   A Composer utility for a specific directory.
+   *
+   * @return bool[]
+   *   An indexed array containing three booleans, in order:
+   *   - Whether the patcher plugin is installed.
+   *   - Whether the patcher plugin is a root requirement in composer.json (in
+   *     either the runtime or dev dependencies).
+   *   - Whether the `composer-exit-on-patch-failure` flag is set in the `extra`
+   *     section of composer.json.
+   */
+  private function computePatcherStatus(ComposerUtility $composer): array {
+    $is_installed = array_key_exists(static::PLUGIN_NAME, $composer->getInstalledPackages());
+
+    $root_package = $composer->getComposer()->getPackage();
+    $is_root_requirement = array_key_exists(static::PLUGIN_NAME, $root_package->getRequires()) || array_key_exists(static::PLUGIN_NAME, $root_package->getDevRequires());
+
+    $extra = $root_package->getExtra();
+    $exit_on_failure = !empty($extra['composer-exit-on-patch-failure']);
+
+    return [$is_installed, $is_root_requirement, $exit_on_failure];
   }
 
   /**
@@ -44,9 +170,9 @@ class ComposerPatchesValidator implements EventSubscriberInterface {
    */
   public static function getSubscribedEvents(): array {
     return [
-      PreCreateEvent::class => 'validateStagePreOperation',
-      PreApplyEvent::class => 'validateStagePreOperation',
-      StatusCheckEvent::class => 'validateStagePreOperation',
+      PreCreateEvent::class => 'validatePatcher',
+      PreApplyEvent::class => 'validatePatcher',
+      StatusCheckEvent::class => 'validatePatcher',
     ];
   }
 
diff --git a/package_manager/tests/fixtures/fake_site/composer.json b/package_manager/tests/fixtures/fake_site/composer.json
index 4c53c626db..0d3c051120 100644
--- a/package_manager/tests/fixtures/fake_site/composer.json
+++ b/package_manager/tests/fixtures/fake_site/composer.json
@@ -45,6 +45,14 @@
       "options": {
         "symlink": false
       }
+    },
+    "cweagans/composer-patches": {
+      "type": "path",
+      "version": "24.12.1999",
+      "url": "../path_repos/cweagans--composer-patches",
+      "options": {
+        "symlink": false
+      }
     }
   },
   "minimum-stability": "stable",
diff --git a/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json b/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json
new file mode 100644
index 0000000000..7998692958
--- /dev/null
+++ b/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json
@@ -0,0 +1,13 @@
+{
+    "name": "cweagans/composer-patches",
+    "type": "composer-plugin",
+    "extra": {
+        "class": "\\cweagans\\Fake\\ComposerPatches"
+    },
+    "require": {
+        "composer-plugin-api": "^1.0 || ^2.0"
+    },
+    "autoload": {
+        "psr-4": {"cweagans\\Fake\\": "src"}
+    }
+}
diff --git a/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php b/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php
new file mode 100644
index 0000000000..65f431d01b
--- /dev/null
+++ b/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace cweagans\Fake;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+
+/**
+ * Dummy composer plugin implementation.
+ */
+class ComposerPatches implements PluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Composer $composer, IOInterface $io) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deactivate(Composer $composer, IOInterface $io) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function uninstall(Composer $composer, IOInterface $io) {}
+
+}
diff --git a/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php b/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
index 54c307715d..c8fa89aacb 100644
--- a/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
+++ b/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
@@ -4,10 +4,12 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Core\Url;
 use Drupal\fixture_manipulator\ActiveFixtureManipulator;
-use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\ValidationResult;
+use Symfony\Component\Process\Process;
 
 /**
  * @covers \Drupal\package_manager\Validator\ComposerPatchesValidator
@@ -16,63 +18,227 @@ use Drupal\package_manager\ValidationResult;
  */
 class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase {
 
+  /**
+   * Data provider for testErrorDuringPreCreate().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerPatcherConfiguration(): array {
+    return [
+      'exit-on-patch-failure missing' => [
+        FALSE,
+        [
+          ValidationResult::createError([
+            t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'),
+          ], t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.')),
+        ],
+      ],
+      'exit-on-patch-failure set' => [
+        TRUE,
+        [],
+      ],
+    ];
+  }
+
   /**
    * Tests that the patcher configuration is validated during pre-create.
+   *
+   * @param bool $extra_key_set
+   *   Whether to set key in extra part of root package.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   *  @dataProvider providerPatcherConfiguration()
    */
-  public function testError(): void {
-    // Simulate an active directory where the patcher is installed, but there's
-    // no composer-exit-on-patch-failure flag.
-    $dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-
-    $this->installPatcherInActive($dir);
-
-    // Because ComposerUtility reads composer.json and passes it to the Composer
-    // factory as an array, Composer will assume that the configuration is
-    // coming from a config.json file, even if one doesn't exist.
-    $error = ValidationResult::createError([
-      t('The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of @dir/composer.json.', [
-        '@dir' => realpath($dir),
-      ]),
-    ]);
-    $this->assertStatusCheckResults([$error]);
-    $this->assertResults([$error], PreCreateEvent::class);
+  public function testPatcherConfiguration(bool $extra_key_set, array $expected_results): void {
+    $this->addPatcherToAllowedPlugins();
+    $this->setRootRequires();
+    if ($extra_key_set) {
+      $this->setRootExtra();
+    }
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
   }
 
   /**
-   * Tests that the patcher configuration is validated during pre-apply.
+   * Data provider for testErrorDuringPreApply() and testHelpLink().
+   *
+   * @return mixed[][]
+   *   The test cases.
    */
-  public function testErrorDuringPreApply(): void {
-    // Simulate an active directory where the patcher is installed, but there's
-    // no composer-exit-on-patch-failure flag.
-    $dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-
-    $this->addEventTestListener(function () use ($dir): void {
-      $this->installPatcherInActive($dir);
-    });
-    // Because ComposerUtility reads composer.json and passes it to the Composer
-    // factory as an array, Composer will assume that the configuration is
-    // coming from a config.json file, even if one doesn't exist.
-    $error = ValidationResult::createError([
-      "The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of $dir/composer.json.",
-    ]);
-    $this->assertResults([$error], PreApplyEvent::class);
+  public function providerErrorDuringPreApply(): array {
+    $summary = t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.');
+
+    return [
+      'composer-patches present in stage, but not present in active' => [
+        FALSE,
+        TRUE,
+        [
+          ValidationResult::createError([
+            t('It cannot be installed by Package Manager.'),
+            t('It must be a root dependency.'),
+            t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'),
+          ], $summary),
+        ],
+        [
+          'package-manager-faq-composer-patches-installed-or-removed',
+          'package-manager-faq-composer-patches-not-a-root-dependency',
+          NULL,
+        ],
+      ],
+      'composer-patches removed in stage, but present in active' => [
+        TRUE,
+        FALSE,
+        [
+          ValidationResult::createError([
+            t('It cannot be removed by Package Manager.'),
+          ], $summary),
+        ],
+        [
+          'package-manager-faq-composer-patches-installed-or-removed',
+        ],
+      ],
+      'composer-patches present in stage and active' => [
+        TRUE,
+        TRUE,
+        [],
+        [],
+      ],
+      'composer-patches not present in stage and active' => [
+        FALSE,
+        FALSE,
+        [],
+        [],
+      ],
+    ];
   }
 
   /**
-   * Simulates that the patcher is installed in the active directory.
+   * Tests the patcher's presence and configuration are validated on pre-apply.
    *
-   * @param string $dir
-   *   The active directory.
+   * @param bool $in_active
+   *   Whether patcher is installed in active.
+   * @param bool $in_stage
+   *   Whether patcher is installed in stage.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerErrorDuringPreApply
    */
-  private function installPatcherInActive(string $dir): void {
+  public function testErrorDuringPreApply(bool $in_active, bool $in_stage, array $expected_results): void {
+    if ($in_active) {
+      // Add patcher as a root dependency and set
+      // `composer-exit-on-patch-failure` to true.
+      $this->addPatcherToAllowedPlugins();
+      $this->setRootRequires();
+      $this->setRootExtra();
+    }
+    if ($in_stage && !$in_active) {
+      // Simulate a stage directory where the patcher is installed.
+      $this->getStageFixtureManipulator()
+        ->addPackage([
+          'name' => 'cweagans/composer-patches',
+          'version' => '24.12.1999',
+          'type' => 'composer-plugin',
+        ]);
+    }
+
+    if (!$in_stage && $in_active) {
+      $this->getStageFixtureManipulator()
+        ->removePackage('cweagans/composer-patches');
+    }
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+    $stage->require(['drupal/core:9.8.1']);
+    $event = new StatusCheckEvent($stage, []);
+    $this->container->get('event_dispatcher')->dispatch($event);
+    $this->assertValidationResultsEqual($expected_results, $event->getResults(), NULL, $stage_dir);
+
+    try {
+      $stage->apply();
+      // If we didn't get an exception, ensure we didn't expect any errors
+      $this->assertSame([], $expected_results);
+    }
+    catch (TestStageValidationException $e) {
+      $this->assertNotEmpty($expected_results);
+      $this->assertValidationResultsEqual($expected_results, $e->getResults(), NULL, $stage_dir);
+    }
+  }
+
+  /**
+   * Tests that validation errors can carry links to help.
+   *
+   * @param bool $in_active
+   *   Whether patcher is installed in active.
+   * @param bool $in_stage
+   *   Whether patcher is installed in stage.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param string[] $help_page_sections
+   *   An associative array of fragments (anchors) in the online help. The keys
+   *   should be the numeric indices of the validation result messages which
+   *   should link to those fragments.
+   *
+   * @dataProvider providerErrorDuringPreApply
+   */
+  public function testErrorDuringPreApplyWithHelp(bool $in_active, bool $in_stage, array $expected_results, array $help_page_sections): void {
+    $this->enableModules(['help']);
+
+    foreach ($expected_results as $result_index => $result) {
+      $messages = $result->getMessages();
+
+      foreach ($messages as $message_index => $message) {
+        if ($help_page_sections[$message_index]) {
+          // Get the link to the online documentation for the error message.
+          $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+            ->setOption('fragment', $help_page_sections[$message_index])
+            ->toString();
+          // Reformat the provided results so that they all have the link to the
+          // online documentation appended to them.
+          $messages[$message_index] = $message . ' See <a href="' . $url . '">the help page</a> for information on how to resolve the problem.';
+        }
+      }
+      $expected_results[$result_index] = ValidationResult::createError($messages, $result->getSummary());
+    }
+    $this->testErrorDuringPreApply($in_active, $in_stage, $expected_results);
+  }
+
+  /**
+   * Add the installed patcher to allowed plugins.
+   */
+  private function addPatcherToAllowedPlugins(): void {
     (new ActiveFixtureManipulator())
-      ->addPackage([
-        'name' => 'cweagans/composer-patches',
-        'version' => '1.0.0',
-        'type' => 'composer-plugin',
-      ])->commitChanges();
+      ->addConfig([
+        'allow-plugins' => [
+          'cweagans/composer-patches' => TRUE,
+        ],
+      ])
+      ->commitChanges();
+  }
+
+  /**
+   * Sets the cweagans/composer-patches as required package for root package.
+   */
+  private function setRootRequires(): void {
+    $process = new Process(
+      ['composer', 'require', "cweagans/composer-patches:@dev"],
+      $this->container->get('package_manager.path_locator')->getProjectRoot()
+    );
+    $process->mustRun();
+  }
+
+  /**
+   * Sets the composer-exit-on-patch-failure key in extra part of root package.
+   */
+  private function setRootExtra(): void {
+    $process = new Process(
+      ['composer', 'config', 'extra.composer-exit-on-patch-failure', 'true'],
+      $this->container->get('package_manager.path_locator')->getProjectRoot()
+    );
+    $process->mustRun();
   }
 
 }
diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index 499ab51fb9..e277e02eba 100644
--- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -245,6 +245,12 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
     $this->assertTrue(mkdir($active_dir));
     static::copyFixtureFilesTo($source_dir, $active_dir);
 
+    // Make sure that the path repositories exist in the test project too.
+    (new Filesystem())->mirror(__DIR__ . '/../../fixtures/path_repos', $root . DIRECTORY_SEPARATOR . 'path_repos', NULL, [
+      'override' => TRUE,
+      'delete' => FALSE,
+    ]);
+
     // Removing 'vfs://root/' from site path set in
     // \Drupal\KernelTests\KernelTestBase::setUpFilesystem as we don't use vfs.
     $test_site_path = str_replace('vfs://root/', '', $this->siteDirectory);
diff --git a/package_manager/tests/src/Traits/ValidationTestTrait.php b/package_manager/tests/src/Traits/ValidationTestTrait.php
index 440b963f19..035c08f7e6 100644
--- a/package_manager/tests/src/Traits/ValidationTestTrait.php
+++ b/package_manager/tests/src/Traits/ValidationTestTrait.php
@@ -28,14 +28,16 @@ trait ValidationTestTrait {
    *   The actual validation results.
    * @param \Drupal\package_manager\PathLocator|null $path_locator
    *   (optional) The path locator (when this trait is used in unit tests).
+   * @param string|null $stage_dir
+   *   (optional) The stage directory.
    */
-  protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL): void {
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): void {
     if ($path_locator) {
       assert(is_a(get_called_class(), UnitTestCase::class, TRUE));
     }
     $expected_results = array_map(
-      function (array $result) use ($path_locator): array {
-        $result['messages'] = $this->resolvePlaceholdersInArrayValuesWithRealPaths($result['messages'], $path_locator);
+      function (array $result) use ($path_locator, $stage_dir): array {
+        $result['messages'] = $this->resolvePlaceholdersInArrayValuesWithRealPaths($result['messages'], $path_locator, $stage_dir);
         return $result;
       },
       $this->getValidationResultsAsArray($expected_results)
@@ -54,19 +56,30 @@ trait ValidationTestTrait {
    *   <STAGE_ROOT_PARENT>.
    * @param \Drupal\package_manager\PathLocator|null $path_locator
    *   (optional) The path locator (when this trait is used in unit tests).
+   * @param string|null $stage_dir
+   *   (optional) The stage directory.
    *
    * @return array
    *   The same array, with unchanged keys, and with the placeholders resolved.
    */
-  protected function resolvePlaceholdersInArrayValuesWithRealPaths(array $subject, ?PathLocator $path_locator = NULL): array {
+  protected function resolvePlaceholdersInArrayValuesWithRealPaths(array $subject, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): array {
     if (!$path_locator) {
       $path_locator = $this->container->get('package_manager.path_locator');
     }
-    return str_replace(
+    $subject = str_replace(
       ['<PROJECT_ROOT>', '<VENDOR_DIR>', '<STAGE_ROOT>', '<STAGE_ROOT_PARENT>'],
       [$path_locator->getProjectRoot(), $path_locator->getVendorDirectory(), $path_locator->getStagingRoot(), dirname($path_locator->getStagingRoot())],
       $subject
     );
+    if ($stage_dir) {
+      $subject = str_replace(['<STAGE_DIR>'], [$stage_dir], $subject);
+    }
+    foreach ($subject as $message) {
+      if (str_contains($message, '<STAGE_DIR>')) {
+        throw new \LogicException("No stage directory passed to replace '<STAGE_DIR>' in message '$message'");
+      }
+    }
+    return $subject;
   }
 
   /**
diff --git a/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php b/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php
index 5fa1700026..a7413bb612 100644
--- a/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php
@@ -42,7 +42,7 @@ class ScaffoldFilePermissionsValidatorTest extends AutomaticUpdatesKernelTestBas
   /**
    * {@inheritdoc}
    */
-  protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL): void {
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): void {
     $map = function (string $path): string {
       return $this->activeDir . '/' . $path;
     };
-- 
GitLab