From 92b7cbf4f4590894868f2fd4b3d4697f0770ac5f Mon Sep 17 00:00:00 2001
From: "Theresa.Grannum" <theresa.grannum@3688861.no-reply.drupal.org>
Date: Fri, 15 Apr 2022 00:43:38 +0000
Subject: [PATCH] Issue #3273812 by Theresa.Grannum: AU Extensions: Create a
 validator to ensure updated packages are secure and supported

---
 .../automatic_updates_extensions.services.yml |   4 +
 .../src/Validator/UpdateReleaseValidator.php  |  67 ++++++++
 .../fixtures/release-history/alpha.1.1.0.xml  |  40 +++++
 .../release-history/semver_test.1.1.xml       |   1 -
 .../tests/src/Build/ModuleUpdateTest.php      |  21 ++-
 ...tomaticUpdatesExtensionsKernelTestBase.php | 154 ++++++++++++++++++
 .../tests/src/Kernel/ExtensionUpdaterTest.php |   1 +
 .../Validator/UpdateReleaseValidatorTest.php  |  66 ++++++++
 .../tests/fixtures/alpha/1.0.0/alpha.info.yml |   3 +
 .../tests/fixtures/alpha/1.1.0/alpha.info.yml |   3 +
 10 files changed, 354 insertions(+), 6 deletions(-)
 create mode 100644 automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php
 create mode 100644 automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml
 create mode 100644 automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
 create mode 100644 automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
 create mode 100644 package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml
 create mode 100644 package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml

diff --git a/automatic_updates_extensions/automatic_updates_extensions.services.yml b/automatic_updates_extensions/automatic_updates_extensions.services.yml
index ba5bd80723..dea4d1ff45 100644
--- a/automatic_updates_extensions/automatic_updates_extensions.services.yml
+++ b/automatic_updates_extensions/automatic_updates_extensions.services.yml
@@ -11,3 +11,7 @@ services:
       - '@event_dispatcher'
       - '@tempstore.shared'
       - '@datetime.time'
+  automatic_updates_extensions.validator.target_release:
+    class: Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator
+    tags:
+      - { name: event_subscriber }
diff --git a/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php b/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php
new file mode 100644
index 0000000000..994ac31e6a
--- /dev/null
+++ b/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\automatic_updates_extensions\Validator;
+
+use Drupal\automatic_updates\ProjectInfo;
+use Drupal\automatic_updates_extensions\ExtensionUpdater;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates that updated projects are secure and supported.
+ */
+class UpdateReleaseValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the update projects are secure and supported.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   */
+  public function checkRelease(PreCreateEvent $event): void {
+    $stage = $event->getStage();
+    // This check only works with Automatic Updates Extensions.
+    if (!$stage instanceof ExtensionUpdater) {
+      return;
+    }
+
+    $all_versions = $stage->getPackageVersions();
+    $messages = [];
+    foreach (['production', 'dev'] as $package_type) {
+      foreach ($all_versions[$package_type] as $package_name => $version) {
+        $package_parts = explode('/', $package_name);
+        $project_name = $package_parts[1];
+        // If the version isn't in the list of installable releases, then it
+        // isn't secure and supported and the user should receive an error.
+        $releases = (new ProjectInfo($project_name))->getInstallableReleases();
+        if (empty($releases) || !array_key_exists($version, $releases)) {
+          $messages[] = $this->t('Project @project_name to version @version', [
+            '@project_name' => $project_name,
+            '@version' => $version,
+          ]);
+        }
+      }
+    }
+    if ($messages) {
+      $summary = $this->formatPlural(
+        count($messages),
+        'Cannot update because the following project version is not in the list of installable releases.',
+        'Cannot update because the following project versions are not in the list of installable releases.'
+      );
+      $event->addError($messages, $summary);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'checkRelease',
+    ];
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml b/automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml
new file mode 100644
index 0000000000..1bcdbbad80
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/release-history/alpha.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>Alpha</title>
+<short_name>alpha</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/alpha</link>
+  <terms>
+   <term><name>Projects</name><value>Alpha project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>Alpha 1.1.0</name>
+    <version>1.1.0</version>
+    <tag>1.1.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/alpha-1-1-0-release</release_link>
+    <download_link>http://example.com/alpha-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>Alpha 1.0.0</name>
+   <version>1.0.0</version>
+   <tag>1.0.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/alpha-1-0-0-release</release_link>
+   <download_link>http://example.com/alpha-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/fixtures/release-history/semver_test.1.1.xml b/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml
index cdb353fd42..addca0b25b 100644
--- a/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml
+++ b/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml
@@ -11,7 +11,6 @@
   </terms>
 <releases>
   <release>
-    <!-- This release is not in a supported branch; therefore it should not be recommended. -->
     <name>Semver Test 8.2.0</name>
     <version>8.2.0</version>
     <tag>8.2.0</tag>
diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
index f84c55f974..16c59dcf1f 100644
--- a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
+++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
@@ -2,27 +2,38 @@
 
 namespace Drupal\Tests\automatic_updates_extensions\Build;
 
-use Drupal\Tests\package_manager\Build\TemplateProjectTestBase;
+use Drupal\Tests\automatic_updates\Build\UpdateTestBase;
 
 /**
  * Tests updating modules in a staging area.
  *
  * @group automatic_updates_extensions
  */
-class ModuleUpdateTest extends TemplateProjectTestBase {
+class ModuleUpdateTest extends UpdateTestBase {
 
   /**
    * Tests updating a module in a staging area.
    */
   public function testApi(): void {
     $this->createTestProject('RecommendedProject');
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.1-security.xml',
+      'alpha'  => __DIR__ . '/../../fixtures/release-history/alpha.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']];
+    $system_info = var_export($system_info, TRUE);
+    $code = <<<END
+\$config['update_test.settings']['system_info'] = $system_info;
+END;
+    $this->writeSettings($code);
 
     $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->installQuickStart('minimal');
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->installModules(['automatic_updates_extensions_test_api']);
+    $this->installModules(['automatic_updates_extensions_test_api', 'alpha']);
 
     // Change both modules' upstream version.
     $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0');
diff --git a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
new file mode 100644
index 0000000000..58f637515d
--- /dev/null
+++ b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates_extensions\Kernel;
+
+use Drupal\automatic_updates_extensions\ExtensionUpdater;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\Utils;
+use Psr\Http\Message\RequestInterface;
+
+/**
+ * Base class for kernel tests of the Automatic Updates Extensions module.
+ */
+abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates_extensions',
+    'automatic_updates_test_release_history',
+  ];
+
+  /**
+   * The client.
+   *
+   * @var \GuzzleHttp\Client
+   */
+  protected $client;
+
+  /**
+   * Asserts validation results are returned from a stage life cycle event.
+   *
+   * @param string[] $project_versions
+   *   The project versions.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param string|null $event_class
+   *   (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 {
+    $updater = $this->createExtensionUpdater();
+
+    try {
+      $updater->begin($project_versions);
+      $updater->stage();
+      $updater->apply();
+      $updater->destroy();
+
+      // If we did not get an exception, ensure we didn't expect any results.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertNotEmpty($expected_results);
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+      // TestStage::dispatch() attaches the event object to the exception so
+      // that we can analyze it.
+      $this->assertNotEmpty($event_class);
+      $this->assertInstanceOf($event_class, $e->event);
+    }
+  }
+
+  /**
+   * Sets the release metadata file to use when fetching available updates.
+   *
+   * @param string[] $files
+   *   The paths of the XML metadata files to use, keyed by project name.
+   */
+  protected function setReleaseMetadataForProjects(array $files): void {
+    $responses = [];
+
+    foreach ($files as $project => $file) {
+      $metadata = Utils::tryFopen($file, 'r');
+      $responses["/release-history/$project/current"] = new Response(200, [], Utils::streamFor($metadata));
+    }
+    $callable = function (RequestInterface $request) use ($responses): Response {
+      return $responses[$request->getUri()->getPath()] ?? new Response(404);
+    };
+
+    // The mock handler's queue consist of same callable as many times as the
+    // number of requests we expect to be made for update XML because it will
+    // retrieve one item off the queue for each request.
+    // @see \GuzzleHttp\Handler\MockHandler::__invoke()
+    $handler = new MockHandler(array_fill(0, count($responses), $callable));
+    $this->client = new Client([
+      'handler' => HandlerStack::create($handler),
+    ]);
+    $this->container->set('http_client', $this->client);
+  }
+
+  /**
+   * Creates an extension updater object for testing purposes.
+   *
+   * @return \Drupal\Tests\automatic_updates_extensions\Kernel\TestExtensionUpdater
+   *   A extension updater object, with test-only modifications.
+   */
+  protected function createExtensionUpdater(): TestExtensionUpdater {
+    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')
+    );
+  }
+
+}
+
+/**
+ * Defines a updater specifically for testing purposes.
+ */
+class TestExtensionUpdater extends ExtensionUpdater {
+
+  /**
+   * The directory where staging areas will be created.
+   *
+   * @var string
+   */
+  public static $stagingRoot;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getStagingRoot(): string {
+    return static::$stagingRoot ?: parent::getStagingRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
+    try {
+      parent::dispatch($event, $on_error);
+    }
+    catch (StageException $e) {
+      // Attach the event object to the exception so that test code can verify
+      // that the exception was thrown when a specific event was dispatched.
+      $e->event = $event;
+      throw $e;
+    }
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php
index 1305b87a31..abfd48db75 100644
--- a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php
+++ b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php
@@ -28,6 +28,7 @@ class ExtensionUpdaterTest extends AutomaticUpdatesKernelTestBase {
    * {@inheritdoc}
    */
   protected function setUp(): void {
+    $this->disableValidators[] = 'automatic_updates_extensions.validator.target_release';
     parent::setUp();
     $this->installEntitySchema('user');
   }
diff --git a/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
new file mode 100644
index 0000000000..4c6fccfaef
--- /dev/null
+++ b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates_extensions\Kernel\Valdiator;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\automatic_updates_extensions\Kernel\AutomaticUpdatesExtensionsKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator
+ *
+ * @group automatic_updates_extensions
+ */
+class UpdateReleaseValidatorTest extends AutomaticUpdatesExtensionsKernelTestBase {
+
+  /**
+   * Tests updating to a release.
+   *
+   * @param string $installed_version
+   *   The installed version of the project.
+   * @param string $update_version
+   *   The version to update to.
+   * @param bool $error_expected
+   *   Whether an error is expected in the update.
+   *
+   * @dataProvider providerTestRelease
+   */
+  public function testRelease(string $installed_version, string $update_version, bool $error_expected) {
+    $this->enableModules(['semver_test']);
+    $module_info = ['version' => $installed_version, 'project' => 'semver_test'];
+    $this->config('update_test.settings')
+      ->set("system_info.semver_test", $module_info)
+      ->save();
+    $this->setReleaseMetadataForProjects([
+      'semver_test' => __DIR__ . '/../../../fixtures/release-history/semver_test.1.1.xml',
+      'drupal' => __DIR__ . '/../../../../../tests/fixtures/release-history/drupal.9.8.2.xml',
+    ]);
+    if ($error_expected) {
+      $expected_results = [
+        ValidationResult::createError(
+          ["Project semver_test to version $update_version"],
+          t('Cannot update because the following project version is not in the list of installable releases.')
+        ),
+      ];
+    }
+    else {
+      $expected_results = [];
+    }
+
+    $this->assertUpdaterResults(['semver_test' => $update_version], $expected_results, PreCreateEvent::class);
+  }
+
+  /**
+   * Data provider for testRelease().
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerTestRelease() {
+    return [
+      'supported update' => ['8.1.0', '8.1.1', FALSE],
+      'update to unsupported branch' => ['8.1.0', '8.2.0', TRUE],
+    ];
+  }
+
+}
diff --git a/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml b/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml
new file mode 100644
index 0000000000..565d3142d8
--- /dev/null
+++ b/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml
@@ -0,0 +1,3 @@
+name: Alpha
+type: module
+core_version_requirement: ^9
diff --git a/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml b/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml
new file mode 100644
index 0000000000..565d3142d8
--- /dev/null
+++ b/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml
@@ -0,0 +1,3 @@
+name: Alpha
+type: module
+core_version_requirement: ^9
-- 
GitLab