From 32f733eb2715b809adbf7baed4c682df8e9f398b Mon Sep 17 00:00:00 2001
From: Yash Rode <57207-yash.rode@users.noreply.drupalcode.org>
Date: Fri, 21 Apr 2023 11:25:40 +0000
Subject: [PATCH] Issue #3353219 by yash.rode, phenaproxima, Wim Leers,
 omkar.podey: Create a PreApply validator that prevents Drupal projects from
 being removed if they are enabled

---
 package_manager/package_manager.services.yml  |   4 +
 .../src/PackageManagerServiceProvider.php     |   1 +
 .../Validator/EnabledExtensionsValidator.php  |  88 ++++++++++
 .../Kernel/EnabledExtensionsValidatorTest.php | 165 ++++++++++++++++++
 4 files changed, 258 insertions(+)
 create mode 100644 package_manager/src/Validator/EnabledExtensionsValidator.php
 create mode 100644 package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php

diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml
index 791172e209..28b08b69ba 100644
--- a/package_manager/package_manager.services.yml
+++ b/package_manager/package_manager.services.yml
@@ -112,6 +112,10 @@ services:
     class: Drupal\package_manager\Validator\DuplicateInfoFileValidator
     tags:
       - { name: event_subscriber }
+  package_manager.validator.enabled_extensions:
+    class: Drupal\package_manager\Validator\EnabledExtensionsValidator
+    tags:
+      - { name: event_subscriber }
   package_manager.validator.overwrite_existing_packages:
     class: Drupal\package_manager\Validator\OverwriteExistingPackagesValidator
     tags:
diff --git a/package_manager/src/PackageManagerServiceProvider.php b/package_manager/src/PackageManagerServiceProvider.php
index d6b5b8f055..45c0783bb5 100644
--- a/package_manager/src/PackageManagerServiceProvider.php
+++ b/package_manager/src/PackageManagerServiceProvider.php
@@ -107,6 +107,7 @@ final class PackageManagerServiceProvider extends ServiceProviderBase {
       'tempstore.shared' => 'Drupal\Core\TempStore\SharedTempStoreFactory',
       'class_resolver' => 'Drupal\Core\DependencyInjection\ClassResolverInterface',
       'request_stack' => 'Symfony\Component\HttpFoundation\RequestStack',
+      'theme_handler' => 'Drupal\Core\Extension\ThemeHandlerInterface',
     ];
     foreach ($aliases as $service_id => $alias) {
       if (!$container->hasAlias($alias)) {
diff --git a/package_manager/src/Validator/EnabledExtensionsValidator.php b/package_manager/src/Validator/EnabledExtensionsValidator.php
new file mode 100644
index 0000000000..daeca84b1a
--- /dev/null
+++ b/package_manager/src/Validator/EnabledExtensionsValidator.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Extension\ThemeHandlerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\ComposerInspector;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates no enabled Drupal extensions are removed from the stage directory.
+ *
+ * @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.
+ */
+final class EnabledExtensionsValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs an EnabledExtensionsValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $pathLocator
+   *   The path locator service.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
+   *   The module handler service.
+   * @param \Drupal\package_manager\ComposerInspector $composerInspector
+   *   The Composer inspector service.
+   * @param \Drupal\Core\Extension\ThemeHandlerInterface $themeHandler
+   *   The theme handler service.
+   */
+  public function __construct(
+    private PathLocator $pathLocator,
+    private ModuleHandlerInterface $moduleHandler,
+    private ComposerInspector $composerInspector,
+    private ThemeHandlerInterface $themeHandler
+  ) {}
+
+  /**
+   * Validates that no enabled Drupal extensions have been removed.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function validate(PreApplyEvent $event): void {
+    $active_packages_list = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
+    $stage_packages_list = $this->composerInspector->getInstalledPackagesList($event->stage->getStageDirectory());
+
+    $extensions_list = $this->moduleHandler->getModuleList() + $this->themeHandler->listInfo();
+    foreach ($extensions_list as $extension) {
+      $extension_name = $extension->getName();
+      $package = $active_packages_list->getPackageByDrupalProjectName($extension_name);
+      if ($package && $stage_packages_list->getPackageByDrupalProjectName($extension_name) === NULL) {
+        $removed_project_messages[] = t("'@name' @type (provided by <code>@package</code>)", [
+          '@name' => $extension_name,
+          '@type' => $extension->getType(),
+          '@package' => $package->name,
+        ]);
+      }
+    }
+
+    if (!empty($removed_project_messages)) {
+      $removed_packages_summary = $this->formatPlural(
+        count($removed_project_messages),
+        'The update cannot proceed because the following enabled Drupal extension was removed during the update.',
+        'The update cannot proceed because the following enabled Drupal extensions were removed during the update.'
+      );
+      $event->addError($removed_project_messages, $removed_packages_summary);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      PreApplyEvent::class => 'validate',
+    ];
+  }
+
+}
diff --git a/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php b/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php
new file mode 100644
index 0000000000..617ee41191
--- /dev/null
+++ b/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\Extension\Extension;
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait;
+
+/**
+ * @covers \Drupal\package_manager\Validator\EnabledExtensionsValidator
+ * @group package_manager
+ * @internal
+ */
+class EnabledExtensionsValidatorTest extends PackageManagerKernelTestBase {
+
+  use ComposerInstallersTrait;
+
+  /**
+   * Data provider for testExtensionRemoved().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerExtensionRemoved(): array {
+    $summary = t('The update cannot proceed because the following enabled Drupal extension was removed during the update.');
+    return [
+      'module' => [
+        [
+          [
+            'name' => 'drupal/test_module2',
+            'version' => '1.3.1',
+            'type' => 'drupal-module',
+          ],
+        ],
+        [
+          ValidationResult::createError([t("'test_module2' module (provided by <code>drupal/test_module2</code>)")], $summary),
+        ],
+      ],
+      'module and theme' => [
+        [
+          [
+            'name' => 'drupal/test_module1',
+            'version' => '1.3.1',
+            'type' => 'drupal-module',
+          ],
+          [
+            'name' => 'drupal/test_theme',
+            'version' => '1.3.1',
+            'type' => 'drupal-theme',
+          ],
+        ],
+        [
+          ValidationResult::createError([
+            t("'test_module1' module (provided by <code>drupal/test_module1</code>)"),
+            t("'test_theme' theme (provided by <code>drupal/test_theme</code>)"),
+          ], t('The update cannot proceed because the following enabled Drupal extensions were removed during the update.')),
+        ],
+      ],
+      'profile' => [
+        [
+          [
+            'name' => 'drupal/test_profile',
+            'version' => '1.3.1',
+            'type' => 'drupal-profile',
+          ],
+        ],
+        [
+          ValidationResult::createError([t("'test_profile' profile (provided by <code>drupal/test_profile</code>)")], $summary),
+        ],
+      ],
+      'theme' => [
+        [
+          [
+            'name' => 'drupal/test_theme',
+            'version' => '1.3.1',
+            'type' => 'drupal-theme',
+          ],
+        ],
+        [
+          ValidationResult::createError([t("'test_theme' theme (provided by <code>drupal/test_theme</code>)")], $summary),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that error is raised if Drupal modules, profiles or themes are removed.
+   *
+   * @param array $packages
+   *   Packages that will be added to the active directory, and removed from the
+   *   stage directory.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerExtensionRemoved
+   */
+  public function testExtensionRemoved(array $packages, array $expected_results): void {
+    $project_root = $this->container->get(PathLocator::class)->getProjectRoot();
+    $this->installComposerInstallers($project_root);
+    // @todo Remove this in https://www.drupal.org/project/automatic_updates/issues/3355553.
+    $this->setInstallerPaths([], $project_root);
+
+    $active_manipulator = new ActiveFixtureManipulator();
+    $stage_manipulator = $this->getStageFixtureManipulator();
+    foreach ($packages as $package) {
+      $active_manipulator->addPackage($package, FALSE, TRUE);
+      $stage_manipulator->removePackage($package['name']);
+    }
+    $active_manipulator->commitChanges();
+
+    foreach ($packages as $package) {
+      $extension_name = str_replace('drupal/', '', $package['name']);
+      $extension = self::createExtension($project_root, $package['type'], $extension_name);
+
+      if ($extension->getType() === 'theme') {
+        /** @var \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler */
+        $theme_handler = $this->container->get('theme_handler');
+        $theme_handler->addTheme($extension);
+        $this->assertArrayHasKey($extension_name, $theme_handler->listInfo());
+      }
+      else {
+        /** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */
+        $module_handler = $this->container->get('module_handler');
+        $module_list = $module_handler->getModuleList();
+        $module_list[$extension_name] = $extension;
+        $module_handler->setModuleList($module_list);
+        $this->assertArrayHasKey($extension_name, $module_handler->getModuleList());
+      }
+    }
+    $this->assertResults($expected_results, PreApplyEvent::class);
+  }
+
+  /**
+   * Returns a mocked extension object for a package.
+   *
+   * @param string $project_root
+   *   The project root directory.
+   * @param string $package_type
+   *   The package type (e.g., `drupal-module` or `drupal-theme`).
+   * @param string $extension_name
+   *   The name of the extension.
+   *
+   * @return \Drupal\Core\Extension\Extension
+   *   An extension object.
+   */
+  private static function createExtension(string $project_root, string $package_type, string $extension_name): Extension {
+    $type = match ($package_type) {
+      'drupal-theme' => 'theme',
+      'drupal-profile' => 'profile',
+      default => 'module',
+    };
+    $subdirectory = match ($type) {
+      'theme' => 'themes',
+      'profile' => 'profiles',
+      'module' => 'modules',
+    };
+    return new Extension($project_root, $type, "$subdirectory/contrib/$extension_name/$extension_name.info.yml");
+  }
+
+}
-- 
GitLab