From eb064d8c6c840da3f79bb506772c3b5db4957fba Mon Sep 17 00:00:00 2001
From: "kunal.sachdev" <kunal.sachdev@3685163.no-reply.drupal.org>
Date: Thu, 9 Jun 2022 13:22:16 +0000
Subject: [PATCH] Issue #3273017 by kunal.sachdev, tedbow: Create a validator
 to confirm that extensions being updated were installed via Composer

---
 .../automatic_updates_extensions.services.yml |   6 +
 ...PackagesInstalledWithComposerValidator.php |  97 ++++++++++
 .../fixtures/new_module/1.0.0/composer.json   |   5 +
 .../new_module/1.0.0/new_module.info.yml      |   3 +
 .../fixtures/new_module/1.1.0/composer.json   |   5 +
 .../new_module/1.1.0/new_module.info.yml      |   3 +
 .../active.installed.json                     |  44 +++++
 ...module_not_installed.staged.installed.json |  34 ++++
 ...ndency_not_installed.staged.installed.json |  54 ++++++
 ...rofile_not_installed.staged.installed.json |  34 ++++
 .../theme_not_installed.staged.installed.json |  34 ++++
 .../release-history/new_module.1.1.0.xml      |  40 ++++
 .../tests/src/Build/ModuleUpdateTest.php      |  49 ++++-
 .../tests/src/Functional/UpdaterFormTest.php  |   5 +
 ...tomaticUpdatesExtensionsKernelTestBase.php |  43 ++++-
 ...agesInstalledWithComposerValidatorTest.php | 180 ++++++++++++++++++
 .../Validator/UpdateReleaseValidatorTest.php  |   8 +
 .../tests/src/Traits/FormTestTrait.php        |  12 +-
 18 files changed, 642 insertions(+), 14 deletions(-)
 create mode 100644 automatic_updates_extensions/src/Validator/PackagesInstalledWithComposerValidator.php
 create mode 100644 automatic_updates_extensions/tests/fixtures/new_module/1.0.0/composer.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/new_module/1.0.0/new_module.info.yml
 create mode 100644 automatic_updates_extensions/tests/fixtures/new_module/1.1.0/composer.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/new_module/1.1.0/new_module.info.yml
 create mode 100644 automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/active.installed.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_not_installed.staged.installed.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_theme_profile_dependency_not_installed.staged.installed.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/profile_not_installed.staged.installed.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/theme_not_installed.staged.installed.json
 create mode 100644 automatic_updates_extensions/tests/fixtures/release-history/new_module.1.1.0.xml
 create mode 100644 automatic_updates_extensions/tests/src/Kernel/Validator/PackagesInstalledWithComposerValidatorTest.php

diff --git a/automatic_updates_extensions/automatic_updates_extensions.services.yml b/automatic_updates_extensions/automatic_updates_extensions.services.yml
index dea4d1ff45..a8cadd7798 100644
--- a/automatic_updates_extensions/automatic_updates_extensions.services.yml
+++ b/automatic_updates_extensions/automatic_updates_extensions.services.yml
@@ -11,6 +11,12 @@ services:
       - '@event_dispatcher'
       - '@tempstore.shared'
       - '@datetime.time'
+  automatic_updates_extensions.validator.packages_installed_with_composer:
+    class: Drupal\automatic_updates_extensions\Validator\PackagesInstalledWithComposerValidator
+    arguments:
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
   automatic_updates_extensions.validator.target_release:
     class: Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator
     tags:
diff --git a/automatic_updates_extensions/src/Validator/PackagesInstalledWithComposerValidator.php b/automatic_updates_extensions/src/Validator/PackagesInstalledWithComposerValidator.php
new file mode 100644
index 0000000000..82ac5d8897
--- /dev/null
+++ b/automatic_updates_extensions/src/Validator/PackagesInstalledWithComposerValidator.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\automatic_updates_extensions\Validator;
+
+use Composer\Package\PackageInterface;
+use Drupal\automatic_updates_extensions\ExtensionUpdater;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates packages are installed via Composer.
+ */
+class PackagesInstalledWithComposerValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs a InstalledPackagesValidator object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(TranslationInterface $translation) {
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Validates that packages are installed with composer or not.
+   *
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   The event object.
+   */
+  public function checkPackagesInstalledWithComposer(PreOperationStageEvent $event): void {
+    $stage = $event->getStage();
+    if ($stage instanceof ExtensionUpdater) {
+      $active_composer = $stage->getActiveComposer();
+      $installed_packages = $active_composer->getInstalledPackages();
+      $missing_packages = [];
+      if ($event instanceof PreCreateEvent) {
+        $package_versions = $stage->getPackageVersions();
+        foreach (['production', 'dev'] as $package_type) {
+          $missing_packages = array_merge($missing_packages, array_diff_key($package_versions[$package_type], $installed_packages));
+        }
+      }
+      else {
+        $missing_packages = $stage->getStageComposer()
+          ->getPackagesNotIn($active_composer);
+        // For new dependency added in the stage will are only concerned with
+        // ones that are Drupal projects that have Update XML from Drupal.org
+        // Since the Update module does allow use to check any of these projects
+        // if they don't exist in the active code base. Other types of projects
+        // even if they are in the 'drupal/' namespace they would not have
+        // Update XML on Drupal.org so it doesn't matter if they are in the
+        // active codebase or not.
+        $types = [
+          'drupal-module',
+          'drupal-theme',
+          'drupal-profile',
+        ];
+        $filter = function (PackageInterface $package) use ($types): bool {
+          return in_array($package->getType(), $types);
+        };
+        $missing_packages = array_filter($missing_packages, $filter);
+        // Saving only the packages whose name starts with drupal/.
+        $missing_packages = array_filter($missing_packages, function (string $key) {
+          return strpos($key, 'drupal/') === 0;
+        }, ARRAY_FILTER_USE_KEY);
+      }
+      if ($missing_packages) {
+        $missing_projects = [];
+        foreach ($missing_packages as $package => $version) {
+          // Removing drupal/ from package name for better user presentation.
+          $project = str_replace('drupal/', '', $package);
+          $missing_projects[] = $project;
+        }
+        if ($missing_projects) {
+          $event->addError($missing_projects, $this->t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'));
+        }
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'checkPackagesInstalledWithComposer',
+      PreApplyEvent::class => 'checkPackagesInstalledWithComposer',
+    ];
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/composer.json b/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/composer.json
new file mode 100644
index 0000000000..824b16f969
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/new_module",
+  "type": "drupal-module",
+  "version": "1.0.0"
+}
diff --git a/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/new_module.info.yml b/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/new_module.info.yml
new file mode 100644
index 0000000000..8ffd0cce5c
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/new_module/1.0.0/new_module.info.yml
@@ -0,0 +1,3 @@
+name: 'New module'
+type: module
+core_version_requirement: ^9
diff --git a/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/composer.json b/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/composer.json
new file mode 100644
index 0000000000..9d2d4e95a6
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/new_module",
+  "type": "drupal-module",
+  "version": "1.1.0"
+}
diff --git a/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/new_module.info.yml b/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/new_module.info.yml
new file mode 100644
index 0000000000..8ffd0cce5c
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/new_module/1.1.0/new_module.info.yml
@@ -0,0 +1,3 @@
+name: 'New module'
+type: module
+core_version_requirement: ^9
diff --git a/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/active.installed.json b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/active.installed.json
new file mode 100644
index 0000000000..a220e444fa
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/active.installed.json
@@ -0,0 +1,44 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0",
+            "type": "drupal-module"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1",
+            "type": "drupal-module"
+        },
+        {
+          "name": "drupal/existing_module",
+          "version": "9.8.0",
+          "type": "drupal-module"
+        },
+        {
+          "name": "drupal/existing_theme",
+          "version": "9.8.0",
+          "type": "drupal-theme"
+        },
+        {
+          "name": "drupal/existing_profile",
+          "version": "9.8.0",
+          "type": "drupal-profile"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_not_installed.staged.installed.json b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_not_installed.staged.installed.json
new file mode 100644
index 0000000000..3ad2ba801c
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_not_installed.staged.installed.json
@@ -0,0 +1,34 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0",
+            "type": "drupal-module"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1",
+            "type": "drupal-module"
+        },
+        {
+          "name": "drupal/new_module",
+          "version": "9.8.0",
+          "type": "drupal-module"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_theme_profile_dependency_not_installed.staged.installed.json b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_theme_profile_dependency_not_installed.staged.installed.json
new file mode 100644
index 0000000000..c6b553899b
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/module_theme_profile_dependency_not_installed.staged.installed.json
@@ -0,0 +1,54 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0",
+            "type": "drupal-module"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1",
+            "type": "drupal-module"
+        },
+        {
+          "name": "drupal/new_module",
+          "version": "9.8.0",
+          "type": "drupal-module"
+        },
+        {
+          "name": "not-drupal/new_module1",
+          "version": "9.8.0",
+          "type": "drupal-module"
+        },
+        {
+          "name": "drupal/new_theme",
+          "version": "9.8.0",
+          "type": "drupal-theme"
+        },
+        {
+          "name": "drupal/new_profile",
+          "version": "9.8.0",
+          "type": "drupal-profile"
+        },
+        {
+          "name": "drupal/new_dependency",
+          "version": "9.8.0",
+          "type": "drupal-library"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/profile_not_installed.staged.installed.json b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/profile_not_installed.staged.installed.json
new file mode 100644
index 0000000000..c4c8db4e54
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/profile_not_installed.staged.installed.json
@@ -0,0 +1,34 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0",
+            "type": "drupal-module"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1",
+            "type": "drupal-module"
+        },
+        {
+          "name": "drupal/new_profile",
+          "version": "9.8.0",
+          "type": "drupal-profile"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/theme_not_installed.staged.installed.json b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/theme_not_installed.staged.installed.json
new file mode 100644
index 0000000000..e09bb8a0fd
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/packages_installed_with_composer_validator/theme_not_installed.staged.installed.json
@@ -0,0 +1,34 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0",
+            "type": "drupal-module"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1",
+            "type": "drupal-module"
+        },
+        {
+          "name": "drupal/new_theme",
+          "version": "9.8.0",
+          "type": "drupal-theme"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/release-history/new_module.1.1.0.xml b/automatic_updates_extensions/tests/fixtures/release-history/new_module.1.1.0.xml
new file mode 100644
index 0000000000..0557a19b2d
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/release-history/new_module.1.1.0.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>New Module</title>
+<short_name>new_module</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>1.1.,1.0.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/new_module</link>
+  <terms>
+   <term><name>Projects</name><value>New Module project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>New Module 1.1.0</name>
+    <version>1.1.0</version>
+    <tag>1.1.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/new_module-1-1-0-release</release_link>
+    <download_link>http://example.com/new_module-1-1-0.tar.gz</download_link>
+    <date>1584195300</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+   <name>New Module 1.0.0</name>
+   <version>1.0.0</version>
+   <tag>1.0.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/new_module-1-0-0-release</release_link>
+   <download_link>http://example.com/new_module-1-0-0.tar.gz</download_link>
+   <date>1581603300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
index f66f3b81f3..db4ed3b00f 100644
--- a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
+++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
@@ -4,6 +4,7 @@ namespace Drupal\Tests\automatic_updates_extensions\Build;
 
 use Drupal\Tests\automatic_updates\Build\UpdateTestBase;
 use Drupal\Tests\automatic_updates_extensions\Traits\FormTestTrait;
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
 
 /**
  * Tests updating modules in a staging area.
@@ -22,11 +23,15 @@ class ModuleUpdateTest extends UpdateTestBase {
     $this->setReleaseMetadata([
       'drupal' => __DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.1-security.xml',
       'alpha'  => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml',
+      'new_module' => __DIR__ . '/../../fixtures/release-history/new_module.1.1.0.xml',
     ]);
 
-    // Set 'version' and 'project' for the 'alpha' module to enable the Update
-    // to determine the update status.
-    $system_info = ['alpha' => ['version' => '1.0.0', 'project' => 'alpha']];
+    // Set 'version' and 'project' for the 'alpha' and 'new_module' module to
+    // enable the Update to determine the update status.
+    $system_info = [
+      'alpha' => ['version' => '1.0.0', 'project' => 'alpha'],
+      'new_module' => ['version' => '1.0.0', 'project' => 'new_module'],
+    ];
     $system_info = var_export($system_info, TRUE);
     $code = <<<END
 \$config['update_test.settings']['system_info'] = $system_info;
@@ -36,8 +41,13 @@ END;
     $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.0.0');
     $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha --update-with-all-dependencies', 'project');
     $this->assertModuleVersion('alpha', '1.0.0');
-
-    $this->installModules(['automatic_updates_extensions_test_api', 'alpha']);
+    $fs = new SymfonyFilesystem();
+    $fs->mirror(__DIR__ . '/../../fixtures/new_module', $this->getWorkspaceDirectory() . '/project/web/modules');
+    $this->installModules([
+      'automatic_updates_extensions_test_api',
+      'alpha',
+      'new_module',
+    ]);
 
     // Change both modules' upstream version.
     $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0');
@@ -48,7 +58,22 @@ END;
    */
   public function testApi(): void {
     $this->createTestProject('RecommendedProject');
-
+    // Use the API endpoint to create a stage and update the 'new_module' module
+    // to 1.1.0.
+    // @see \Drupal\automatic_updates_extensions_test_api\ApiController::run()
+    // There will be error in updating as this module is not installed
+    // via composer @see \Drupal\Tests\automatic_updates_extensions\Kernel\Validator\PackagesInstalledWithComposerValidatorTest.
+    $query = http_build_query([
+      'projects' => [
+        'new_module' => '1.1.0',
+      ],
+    ]);
+    $this->visit("/automatic-updates-extensions-test-api?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(500);
+    $page_text = $mink->getSession()->getPage()->getText();
+    $this->assertStringContainsString('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:', $page_text);
+    $this->assertStringContainsString('new_module', $page_text);
     // Use the API endpoint to create a stage and update the 'alpha' module to
     // 1.1.0. We ask the API to return the contents of the module's
     // composer.json file, so we can assert that they were updated to the
@@ -86,7 +111,17 @@ END;
 
     $this->visit('/admin/reports/updates');
     $page->clickLink('Update Extensions');
-    $this->assertUpdateTableRow($assert_session, 'Alpha', '1.0.0', '1.1.0');
+    $this->assertUpdateTableRow($assert_session, 'Alpha', '1.0.0', '1.1.0', 2);
+    $this->assertUpdateTableRow($assert_session, 'New module', '1.0.0', '1.1.0', 1);
+    $page->checkField('projects[new_module]');
+    $page->pressButton('Update');
+    $this->waitForBatchJob();
+    $page_text = $page->getText();
+    // There will be error in updating 'new_module' as it is not installed via
+    // composer @see \Drupal\Tests\automatic_updates_extensions\Kernel\Validator\PackagesInstalledWithComposerValidatorTest.
+    $this->assertStringContainsString('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:', $page_text);
+    $this->assertStringContainsString('new_module', $page_text);
+    $page->clickLink('error page');
     $page->checkField('projects[alpha]');
     $page->pressButton('Update');
     $this->waitForBatchJob();
diff --git a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
index 6d56d9fde2..b4212ed7cc 100644
--- a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
+++ b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
@@ -62,6 +62,11 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
    * {@inheritdoc}
    */
   protected function setUp(): void {
+    // In this test class, some modules are added and this validator will
+    // complain because these are not installed via composer. This validator
+    // already has test coverage.
+    // @see \Drupal\Tests\automatic_updates_extensions\Build\ModuleUpdateTest
+    $this->disableValidators[] = 'automatic_updates_extensions.validator.packages_installed_with_composer';
     parent::setUp();
     $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/semver_test.1.1.xml');
     $user = $this->createUser([
diff --git a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
index 64a276ec88..fcd14e0bfc 100644
--- a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
+++ b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
@@ -3,6 +3,8 @@
 namespace Drupal\Tests\automatic_updates_extensions\Kernel;
 
 use Drupal\automatic_updates_extensions\ExtensionUpdater;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
 use Drupal\Tests\package_manager\Kernel\TestStageTrait;
@@ -26,6 +28,43 @@ abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdates
     'automatic_updates_test_release_history',
   ];
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Use the test-only implementations of the regular and cron updaters.
+    $overrides = [
+      'automatic_updates_extensions.updater' => TestExtensionUpdater::class,
+    ];
+    foreach ($overrides as $service_id => $class) {
+      if ($container->hasDefinition($service_id)) {
+        $container->getDefinition($service_id)->setClass($class);
+      }
+    }
+  }
+
+  /**
+   * Creates a stage object for testing purposes.
+   *
+   * @return \Drupal\automatic_updates_extensions\ExtensionUpdater
+   *   A stage object, with test-only modifications.
+   */
+  protected function createUpdater(): ExtensionUpdater {
+    return new TestExtensionUpdater(
+      $this->container->get('config.factory'),
+      $this->container->get('package_manager.path_locator'),
+      $this->container->get('package_manager.beginner'),
+      $this->container->get('package_manager.stager'),
+      $this->container->get('package_manager.committer'),
+      $this->container->get('file_system'),
+      $this->container->get('event_dispatcher'),
+      $this->container->get('tempstore.shared'),
+      $this->container->get('datetime.time')
+    );
+  }
+
   /**
    * The client.
    *
@@ -44,7 +83,7 @@ abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdates
    *   (optional) The class of the event which should return the results. Must
    *   be passed if $expected_results is not empty.
    */
-  protected function assertUpdaterResults(array $project_versions, array $expected_results, string $event_class = NULL): void {
+  protected function assertUpdateResults(array $project_versions, array $expected_results, string $event_class = NULL): void {
     $updater = $this->createExtensionUpdater();
 
     try {
@@ -117,7 +156,7 @@ abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdates
 }
 
 /**
- * Defines a updater specifically for testing purposes.
+ * A test-only version of the regular extension updater to override internals.
  */
 class TestExtensionUpdater extends ExtensionUpdater {
 
diff --git a/automatic_updates_extensions/tests/src/Kernel/Validator/PackagesInstalledWithComposerValidatorTest.php b/automatic_updates_extensions/tests/src/Kernel/Validator/PackagesInstalledWithComposerValidatorTest.php
new file mode 100644
index 0000000000..6f8b88b45f
--- /dev/null
+++ b/automatic_updates_extensions/tests/src/Kernel/Validator/PackagesInstalledWithComposerValidatorTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates_extensions\Kernel\Validator;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\automatic_updates_extensions\Kernel\AutomaticUpdatesExtensionsKernelTestBase;
+
+/**
+ * Validates the installed packages via composer after an update.
+ *
+ * @coversDefaultClass \Drupal\automatic_updates_extensions\Validator\PackagesInstalledWithComposerValidator
+ *
+ * @group automatic_updates_extensions
+ */
+class PackagesInstalledWithComposerValidatorTest extends AutomaticUpdatesExtensionsKernelTestBase {
+
+  /**
+   * The active directory in the virtual file system.
+   *
+   * @var string
+   */
+  private $activeDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we don't focus on validating that the updated projects are
+    // secure and supported. Therefore, we need to disable the update release
+    // validator that validates updated projects are secure and supported.
+    $this->disableValidators[] = 'automatic_updates_extensions.validator.target_release';
+    parent::setUp();
+    $this->createTestProject();
+    $this->activeDir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+  }
+
+  /**
+   * Data provider for testPreCreateException().
+   *
+   * @return array
+   *   Test cases for testPreCreateException().
+   */
+  public function providerPreCreateException(): array {
+    return [
+      'module not installed via composer' => [
+        [
+          'new_module' => '9.8.0',
+        ],
+        [ValidationResult::createError(['new_module'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      'theme not installed via composer' => [
+        [
+          'new_theme' => '9.8.0',
+        ],
+        [ValidationResult::createError(['new_theme'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      'profile not installed via composer' => [
+        [
+          'new_profile' => '9.8.0',
+        ],
+        [ValidationResult::createError(['new_profile'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      'module_theme_profile_dependency_not_installed_via_composer' => [
+        [
+          'new_module' => '9.8.0',
+          'new_theme' => '9.8.0',
+          'new_profile' => '9.8.0',
+          'new_dependency' => '9.8.0',
+        ],
+        [
+          ValidationResult::createError(
+            ['new_module', 'new_theme', 'new_profile', 'new_dependency'],
+            t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:')),
+        ],
+      ],
+      'module_theme_profile_installed_via_composer' => [
+        [
+          'existing_module' => '9.8.1',
+          'existing_theme' => '9.8.1',
+          'existing_profile' => '9.8.1',
+        ],
+        [],
+      ],
+      'existing module installed and new module not installed via composer' => [
+        [
+          'existing_module' => '9.8.1',
+          'new_module' => '9.8.0',
+        ],
+        [ValidationResult::createError(['new_module'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+    ];
+  }
+
+  /**
+   * Tests the packages installed with composer during pre-create.
+   *
+   * @param array $projects
+   *   The projects to install.
+   * @param array $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerPreCreateException
+   */
+  public function testPreCreateException(array $projects, array $expected_results): void {
+    // Path of `active.installed.json` file. It will be used as the virtual
+    // project's active `vendor/composer/installed.json` file.
+    $active_installed = __DIR__ . '/../../../fixtures/packages_installed_with_composer_validator/active.installed.json';
+    $this->assertFileIsReadable($active_installed);
+    copy($active_installed, "$this->activeDir/vendor/composer/installed.json");
+    $this->assertUpdateResults($projects, $expected_results, PreCreateEvent::class);
+  }
+
+  /**
+   * Data provider for testPreApplyException().
+   *
+   * @return array
+   *   Test cases for testPreApplyException().
+   */
+  public function providerPreApplyException(): array {
+    $fixtures_folder = __DIR__ . '/../../../fixtures/packages_installed_with_composer_validator';
+    return [
+      'module not installed via composer' => [
+        "$fixtures_folder/module_not_installed.staged.installed.json",
+        [ValidationResult::createError(['new_module'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      'theme not installed via composer' => [
+        "$fixtures_folder/theme_not_installed.staged.installed.json",
+        [ValidationResult::createError(['new_theme'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      'profile not installed via composer' => [
+        "$fixtures_folder/profile_not_installed.staged.installed.json",
+        [ValidationResult::createError(['new_profile'], t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:'))],
+      ],
+      // Dependency drupal/new_dependency of type 'drupal-library' will not show
+      // up in the error because it is not one of the covered types
+      // ('drupal-module', 'drupal-theme' or 'drupal-profile'). Module
+      // new_module1 will also not show up as it's name doesn't start with
+      // 'drupal/'.
+      // @see \Drupal\automatic_updates_extensions\Validator\PackagesInstalledWithComposerValidator
+      'module_theme_profile_dependency_not_installed_via_composer' => [
+        "$fixtures_folder/module_theme_profile_dependency_not_installed.staged.installed.json",
+        [
+          ValidationResult::createError(
+            ['new_module', 'new_theme', 'new_profile'],
+            t('Automatic Updates can only update projects that were installed via Composer. The following packages are not installed through composer:')),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests the packages installed with composer during pre-apply.
+   *
+   * @param string $staged_installed
+   *   Path of `staged.installed.json` file. It will be used as the virtual
+   *   project's staged `vendor/composer/installed.json` file.
+   * @param array $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerPreApplyException
+   */
+  public function testPreApplyException(string $staged_installed, array $expected_results): void {
+    // Path of `active.installed.json` file. It will be used as the virtual
+    // project's active `vendor/composer/installed.json` file.
+    $active_installed = __DIR__ . '/../../../fixtures/packages_installed_with_composer_validator/active.installed.json';
+    $this->assertFileIsReadable($active_installed);
+    $this->assertFileIsReadable($staged_installed);
+    copy($active_installed, "$this->activeDir/vendor/composer/installed.json");
+    $listener = function (PreApplyEvent $event) use ($staged_installed): void {
+      $stage_dir = $event->getStage()->getStageDirectory();
+      copy($staged_installed, $stage_dir . "/vendor/composer/installed.json");
+    };
+    $this->container->get('event_dispatcher')->addListener(PreApplyEvent::class, $listener, 1000);
+    $this->assertUpdateResults([], $expected_results, PreApplyEvent::class);
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
index eea42e80b0..ab79f25491 100644
--- a/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
+++ b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
@@ -14,6 +14,14 @@ use Drupal\Tests\automatic_updates_extensions\Kernel\AutomaticUpdatesExtensionsK
  */
 class UpdateReleaseValidatorTest extends AutomaticUpdatesExtensionsKernelTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    $this->disableValidators[] = 'automatic_updates_extensions.validator.packages_installed_with_composer';
+    parent::setUp();
+  }
+
   /**
    * Tests updating to a release.
    *
diff --git a/automatic_updates_extensions/tests/src/Traits/FormTestTrait.php b/automatic_updates_extensions/tests/src/Traits/FormTestTrait.php
index 33d4bd1005..3b12fa8c95 100644
--- a/automatic_updates_extensions/tests/src/Traits/FormTestTrait.php
+++ b/automatic_updates_extensions/tests/src/Traits/FormTestTrait.php
@@ -20,12 +20,14 @@ trait FormTestTrait {
    *   The expected installed version.
    * @param string $expected_target_version
    *   The expected target version.
+   * @param int $row
+   *   The row number.
    */
-  private function assertUpdateTableRow(WebAssert $assert, string $expected_project_title, string $expected_installed_version, string $expected_target_version): void {
-    $assert->elementTextContains('css', '.update-recommended td:nth-of-type(2)', $expected_project_title);
-    $assert->elementTextContains('css', '.update-recommended td:nth-of-type(3)', $expected_installed_version);
-    $assert->elementTextContains('css', '.update-recommended td:nth-of-type(4)', $expected_target_version);
-    $assert->elementsCount('css', '.update-recommended tbody tr', 1);
+  private function assertUpdateTableRow(WebAssert $assert, string $expected_project_title, string $expected_installed_version, string $expected_target_version, int $row = 1): void {
+    $row_selector = ".update-recommended tr:nth-of-type($row)";
+    $assert->elementTextContains('css', $row_selector . ' td:nth-of-type(2)', $expected_project_title);
+    $assert->elementTextContains('css', $row_selector . ' td:nth-of-type(3)', $expected_installed_version);
+    $assert->elementTextContains('css', $row_selector . ' td:nth-of-type(4)', $expected_target_version);
   }
 
 }
-- 
GitLab