From 2487d9c2feae0887d75ffc5cc37e890cc079ff8b Mon Sep 17 00:00:00 2001
From: tedbow <tedbow@240860.no-reply.drupal.org>
Date: Tue, 29 Mar 2022 20:28:40 +0000
Subject: [PATCH] Issue #3254755 by tedbow, kunal.sachdev, phenaproxima: Add a
 seperate Update recommender and update version validator for cron updates

---
 automatic_updates.module                      |   7 +-
 automatic_updates.services.yml                |  16 ++
 src/CronUpdater.php                           |  71 +++---
 src/Form/UpdaterForm.php                      |  47 ++--
 src/ProjectInfo.php                           | 106 +++++++++
 src/ReleaseChooser.php                        | 132 ++++++++++++
 src/UpdateRecommender.php                     |  69 ------
 src/Validation/ReadinessValidationManager.php |  12 +-
 src/Validator/CronUpdateVersionValidator.php  | 100 +++++++++
 src/Validator/UpdateVersionValidator.php      | 204 ++++++++++--------
 src/VersionParsingTrait.php                   |  37 ++++
 .../drupal.9.8.2-older-sec-release.xml        | 102 +++++++++
 .../fixtures/release-history/drupal.9.8.2.xml |  38 +++-
 .../Functional/ReadinessValidationTest.php    |   2 +-
 tests/src/Functional/UpdateLockTest.php       |   2 +-
 tests/src/Kernel/CronUpdaterTest.php          |   2 +-
 .../ReadinessValidationManagerTest.php        |   4 +-
 .../UpdateVersionValidatorTest.php            |  86 +-------
 tests/src/Kernel/ReleaseChooserTest.php       | 171 +++++++++++++++
 tests/src/Kernel/UpdateRecommenderTest.php    |  47 ----
 tests/src/Unit/ProjectInfoTest.php            | 194 +++++++++++++++++
 21 files changed, 1100 insertions(+), 349 deletions(-)
 create mode 100644 src/ProjectInfo.php
 create mode 100644 src/ReleaseChooser.php
 delete mode 100644 src/UpdateRecommender.php
 create mode 100644 src/Validator/CronUpdateVersionValidator.php
 create mode 100644 src/VersionParsingTrait.php
 create mode 100644 tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
 create mode 100644 tests/src/Kernel/ReleaseChooserTest.php
 delete mode 100644 tests/src/Kernel/UpdateRecommenderTest.php
 create mode 100644 tests/src/Unit/ProjectInfoTest.php

diff --git a/automatic_updates.module b/automatic_updates.module
index b683f56218..a11765cb01 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -6,9 +6,9 @@
  */
 
 use Drupal\automatic_updates\BatchProcessor;
+use Drupal\automatic_updates\ProjectInfo;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\automatic_updates\CronUpdater;
-use Drupal\automatic_updates\UpdateRecommender;
 use Drupal\automatic_updates\Validation\AdminReadinessMessages;
 use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\Form\FormStateInterface;
@@ -141,9 +141,8 @@ function automatic_updates_form_update_manager_update_form_alter(&$form, FormSta
  * Implements hook_form_FORM_ID_alter() for 'update_settings' form.
  */
 function automatic_updates_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) {
-  $recommender = new UpdateRecommender();
-  $drupal_project = $recommender->getProjectInfo();
-  $version = ExtensionVersion::createFromVersionString($drupal_project['existing_version']);
+  $project_info = new ProjectInfo();
+  $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion());
   $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
   // @todo In https://www.drupal.org/node/2998285 use the update XML to
   //   determine when the installed of core will become unsupported.
diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index d8d6ca1e7f..516e06545f 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -26,6 +26,7 @@ services:
   automatic_updates.cron_updater:
     class: Drupal\automatic_updates\CronUpdater
     arguments:
+      - '@automatic_updates.cron_release_chooser'
       - '@logger.factory'
       - '@config.factory'
       - '@package_manager.path_locator'
@@ -55,6 +56,21 @@ services:
       - '@config.factory'
     tags:
       - { name: event_subscriber }
+  automatic_updates.cron_update_version_validator:
+    class: Drupal\automatic_updates\Validator\CronUpdateVersionValidator
+    arguments:
+      - '@string_translation'
+      - '@config.factory'
+    tags:
+      - { name: event_subscriber }
+  automatic_updates.release_chooser:
+    class: Drupal\automatic_updates\ReleaseChooser
+    arguments:
+      - '@automatic_updates.update_version_validator'
+  automatic_updates.cron_release_chooser:
+    class: Drupal\automatic_updates\ReleaseChooser
+    arguments:
+      - '@automatic_updates.cron_update_version_validator'
   automatic_updates.composer_executable_validator:
     class: Drupal\automatic_updates\Validator\PackageManagerReadinessCheck
     arguments:
diff --git a/src/CronUpdater.php b/src/CronUpdater.php
index 69d1fb1872..38903875c7 100644
--- a/src/CronUpdater.php
+++ b/src/CronUpdater.php
@@ -42,16 +42,26 @@ class CronUpdater extends Updater {
    */
   protected $logger;
 
+  /**
+   * The cron release chooser service.
+   *
+   * @var \Drupal\automatic_updates\ReleaseChooser
+   */
+  protected $releaseChooser;
+
   /**
    * Constructs a CronUpdater object.
    *
+   * @param \Drupal\automatic_updates\ReleaseChooser $release_chooser
+   *   The cron release chooser service.
    * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
    *   The logger channel factory.
    * @param mixed ...$arguments
    *   Additional arguments to pass to the parent constructor.
    */
-  public function __construct(LoggerChannelFactoryInterface $logger_factory, ...$arguments) {
+  public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, ...$arguments) {
     parent::__construct(...$arguments);
+    $this->releaseChooser = $release_chooser;
     $this->logger = $logger_factory->get('automatic_updates');
   }
 
@@ -59,50 +69,35 @@ class CronUpdater extends Updater {
    * Handles updates during cron.
    */
   public function handleCron(): void {
-    $level = $this->configFactory->get('automatic_updates.settings')
-      ->get('cron');
-
-    // If automatic updates are disabled, bail out.
-    if ($level === static::DISABLED) {
+    if ($this->isDisabled()) {
       return;
     }
 
-    $recommender = new UpdateRecommender();
-    try {
-      $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    }
-    catch (\Throwable $e) {
-      $this->logger->error($e->getMessage());
-      return;
-    }
-
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($recommended_release === NULL) {
-      return;
+    $next_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
+    if ($next_release) {
+      $this->performUpdate($next_release->getVersion());
     }
+  }
 
-    $project = $recommender->getProjectInfo();
-    if (empty($project['existing_version'])) {
+  /**
+   * Performs the update.
+   *
+   * @param string $update_version
+   *   The version to which to update.
+   */
+  private function performUpdate(string $update_version): void {
+    $installed_version = (new ProjectInfo())->getInstalledVersion();
+    if (empty($installed_version)) {
       $this->logger->error('Unable to determine the current version of Drupal core.');
       return;
     }
 
-    // If automatic updates are only enabled for security releases, bail out if
-    // the recommended release is not a security release.
-    if ($level === static::SECURITY && !$recommended_release->isSecurityRelease()) {
-      return;
-    }
-
-    // @todo Use the queue to add update jobs allowing jobs to span multiple
-    //   cron runs.
-    $recommended_version = $recommended_release->getVersion();
-
     // Do the bulk of the update in its own try-catch structure, so that we can
     // handle any exceptions or validation errors consistently, and destroy the
     // stage regardless of whether the update succeeds.
     try {
       $this->begin([
-        'drupal' => $recommended_version,
+        'drupal' => $update_version,
       ]);
       $this->stage();
       $this->apply();
@@ -110,8 +105,8 @@ class CronUpdater extends Updater {
       $this->logger->info(
         'Drupal core has been updated from %previous_version to %update_version',
         [
-          '%previous_version' => $project['existing_version'],
-          '%update_version' => $recommended_version,
+          '%previous_version' => $installed_version,
+          '%update_version' => $update_version,
         ]
       );
     }
@@ -138,6 +133,16 @@ class CronUpdater extends Updater {
     }
   }
 
+  /**
+   * Determines if cron updates are disabled.
+   *
+   * @return bool
+   *   TRUE if cron updates are disabled, otherwise FALSE.
+   */
+  private function isDisabled(): bool {
+    return $this->configFactory->get('automatic_updates.settings')->get('cron') === static::DISABLED;
+  }
+
   /**
    * Generates a log message from a stage validation exception.
    *
diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php
index 7cf23e7dc1..e7d45b0d8d 100644
--- a/src/Form/UpdaterForm.php
+++ b/src/Form/UpdaterForm.php
@@ -4,8 +4,9 @@ namespace Drupal\automatic_updates\Form;
 
 use Drupal\automatic_updates\BatchProcessor;
 use Drupal\automatic_updates\Event\ReadinessCheckEvent;
+use Drupal\automatic_updates\ProjectInfo;
+use Drupal\automatic_updates\ReleaseChooser;
 use Drupal\automatic_updates\Updater;
-use Drupal\automatic_updates\UpdateRecommender;
 use Drupal\automatic_updates\Validation\ReadinessTrait;
 use Drupal\Core\Batch\BatchBuilder;
 use Drupal\Core\Form\FormBase;
@@ -19,7 +20,6 @@ use Drupal\system\SystemManager;
 use Drupal\update\UpdateManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
-use Symfony\Component\HttpFoundation\Session\SessionInterface;
 
 /**
  * Defines a form to update Drupal core.
@@ -53,11 +53,11 @@ class UpdaterForm extends FormBase {
   protected $eventDispatcher;
 
   /**
-   * The current session.
+   * The release chooser service.
    *
-   * @var \Symfony\Component\HttpFoundation\Session\SessionInterface
+   * @var \Drupal\automatic_updates\ReleaseChooser
    */
-  protected $session;
+  protected $releaseChooser;
 
   /**
    * Constructs a new UpdaterForm object.
@@ -68,14 +68,14 @@ class UpdaterForm extends FormBase {
    *   The updater service.
    * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
    *   The event dispatcher service.
-   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
-   *   The current session.
+   * @param \Drupal\automatic_updates\ReleaseChooser $release_chooser
+   *   The release chooser service.
    */
-  public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, SessionInterface $session) {
+  public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser) {
     $this->updater = $updater;
     $this->state = $state;
     $this->eventDispatcher = $event_dispatcher;
-    $this->session = $session;
+    $this->releaseChooser = $release_chooser;
   }
 
   /**
@@ -93,7 +93,7 @@ class UpdaterForm extends FormBase {
       $container->get('state'),
       $container->get('automatic_updates.updater'),
       $container->get('event_dispatcher'),
-      $container->get('session')
+      $container->get('automatic_updates.release_chooser')
     );
   }
 
@@ -112,7 +112,7 @@ class UpdaterForm extends FormBase {
       // If there's a stage ID stored in the session, try to claim the stage
       // with it. If we succeed, then an update is already in progress, and the
       // current session started it, so redirect them to the confirmation form.
-      $stage_id = $this->session->get(BatchProcessor::STAGE_ID_SESSION_KEY);
+      $stage_id = $this->getRequest()->getSession()->get(BatchProcessor::STAGE_ID_SESSION_KEY);
       if ($stage_id) {
         try {
           $this->updater->claim($stage_id);
@@ -131,10 +131,24 @@ class UpdaterForm extends FormBase {
       '#theme' => 'update_last_check',
       '#last' => $this->state->get('update.last_check', 0),
     ];
+    $project_info = new ProjectInfo();
 
-    $recommender = new UpdateRecommender();
     try {
-      $recommended_release = $recommender->getRecommendedRelease(TRUE);
+      // @todo Until https://www.drupal.org/i/3264849 is fixed, we can only show
+      //   one release on the form. First, try to show the latest release in the
+      //   currently installed minor. Failing that, try to show the latest
+      //   release in the next minor. If neither of those are available, just
+      //   show the first available release.
+      $recommended_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
+      if (!$recommended_release) {
+        $recommended_release = $this->releaseChooser->getLatestInNextMinor();
+        if (!$recommended_release) {
+          // @todo Do not list an update that can't be validated in
+          //   https://www.drupal.org/i/3271235.
+          $updates = $project_info->getInstallableReleases();
+          $recommended_release = array_pop($updates);
+        }
+      }
     }
     catch (\RuntimeException $e) {
       $form['message'] = [
@@ -148,6 +162,9 @@ class UpdaterForm extends FormBase {
 
     // If we're already up-to-date, there's nothing else we need to do.
     if ($recommended_release === NULL) {
+      // @todo Link to the Available Updates report if there are other updates
+      //   that are not supported by this module in
+      //   https://www.drupal.org/i/3271235.
       $this->messenger()->addMessage('No update available');
       return $form;
     }
@@ -159,7 +176,7 @@ class UpdaterForm extends FormBase {
       ],
     ];
 
-    $project = $recommender->getProjectInfo();
+    $project = $project_info->getProjectInfo();
     if (empty($project['title']) || empty($project['link'])) {
       throw new \UnexpectedValueException('Expected project data to have a title and link.');
     }
@@ -187,7 +204,7 @@ class UpdaterForm extends FormBase {
       'title' => [
         'data' => $title,
       ],
-      'installed_version' => $project['existing_version'],
+      'installed_version' => $project_info->getInstalledVersion(),
       'recommended_version' => [
         'data' => [
           // @todo Is an inline template the right tool here? Is there an Update
diff --git a/src/ProjectInfo.php b/src/ProjectInfo.php
new file mode 100644
index 0000000000..27101c6bea
--- /dev/null
+++ b/src/ProjectInfo.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Composer\Semver\Comparator;
+use Composer\Semver\Semver;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\update\UpdateManagerInterface;
+
+/**
+ * Defines a class for retrieving project information from Update module.
+ *
+ * @todo Allow passing a project name to handle more than Drupal core in
+ *    https://www.drupal.org/i/3271240.
+ */
+class ProjectInfo {
+
+  /**
+   * Returns up-to-date project information for Drupal core.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return array
+   *   The retrieved project information for Drupal core.
+   *
+   * @throws \RuntimeException
+   *   If data about available updates cannot be retrieved.
+   */
+  public function getProjectInfo(bool $refresh = FALSE): array {
+    $available_updates = update_get_available($refresh);
+    $project_data = update_calculate_project_data($available_updates);
+    return $project_data['drupal'];
+  }
+
+  /**
+   * Gets all releases of Drupal core to which the site can update.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[]
+   *   An array of possible update releases with release versions as keys. The
+   *   releases are in descending order by version number (i.e., higher versions
+   *   are listed first).
+   *
+   * @throws \RuntimeException
+   *   Thrown if $refresh is TRUE and there are no available releases.
+   *
+   * @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
+   */
+  public function getInstallableReleases(bool $refresh = FALSE): array {
+    $project = $this->getProjectInfo($refresh);
+    $installed_version = $this->getInstalledVersion();
+    // If we refreshed and we were able to get available releases we should
+    // always have at least have the current release stored.
+    if ($refresh && empty($project['releases'])) {
+      throw new \RuntimeException('There was a problem getting update information. Try again later.');
+    }
+    // If we're already up-to-date, there's nothing else we need to do.
+    if ($project['status'] === UpdateManagerInterface::CURRENT) {
+      return [];
+    }
+    elseif (empty($project['recommended'])) {
+      // If we don't know what to recommend they update to, time to freak out.
+      throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.');
+    }
+    $installable_releases = [];
+    if (Comparator::greaterThan($project['recommended'], $installed_version)) {
+      $release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]);
+      $installable_releases[$release->getVersion()] = $release;
+    }
+    if (!empty($project['security updates'])) {
+      foreach ($project['security updates'] as $security_update) {
+        $release = ProjectRelease::createFromArray($security_update);
+        $version = $release->getVersion();
+        if (Comparator::greaterThan($version, $installed_version)) {
+          $installable_releases[$version] = $release;
+        }
+      }
+    }
+    $sorted_versions = Semver::rsort(array_keys($installable_releases));
+    return array_replace(array_flip($sorted_versions), $installable_releases);
+  }
+
+  /**
+   * Returns the installed project version, according to the Update module.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return string
+   *   The installed project version as known to the Update module.
+   */
+  public function getInstalledVersion(bool $refresh = FALSE): string {
+    $project_data = $this->getProjectInfo($refresh);
+    return $project_data['existing_version'];
+  }
+
+}
diff --git a/src/ReleaseChooser.php b/src/ReleaseChooser.php
new file mode 100644
index 0000000000..774d0ec5f2
--- /dev/null
+++ b/src/ReleaseChooser.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Composer\Semver\Semver;
+use Drupal\automatic_updates\Validator\UpdateVersionValidator;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\Core\Extension\ExtensionVersion;
+
+/**
+ * Defines a class to choose a release of Drupal core to update to.
+ */
+class ReleaseChooser {
+
+  use VersionParsingTrait;
+
+  /**
+   * The version validator service.
+   *
+   * @var \Drupal\automatic_updates\Validator\UpdateVersionValidator
+   */
+  protected $versionValidator;
+
+  /**
+   * The project information fetcher.
+   *
+   * @var \Drupal\automatic_updates\ProjectInfo
+   */
+  protected $projectInfo;
+
+  /**
+   * Constructs an ReleaseChooser object.
+   *
+   * @param \Drupal\automatic_updates\Validator\UpdateVersionValidator $version_validator
+   *   The version validator.
+   */
+  public function __construct(UpdateVersionValidator $version_validator) {
+    $this->versionValidator = $version_validator;
+    $this->projectInfo = new ProjectInfo();
+  }
+
+  /**
+   * Refreshes the project information through the Update module.
+   *
+   * @return $this
+   *   The called object.
+   */
+  public function refresh(): self {
+    $this->projectInfo->getProjectInfo(TRUE);
+    return $this;
+  }
+
+  /**
+   * Returns the releases that are installable.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[]
+   *   The releases that are installable according to the version validator
+   *   service.
+   */
+  protected function getInstallableReleases(): array {
+    return array_filter(
+      $this->projectInfo->getInstallableReleases(),
+      [$this->versionValidator, 'isValidVersion'],
+      ARRAY_FILTER_USE_KEY
+    );
+  }
+
+  /**
+   * Gets the most recent release in the same minor as a specified version.
+   *
+   * @param string $version
+   *   The full semantic version number, which must include a patch version.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null
+   *   The most recent release in the minor if available, otherwise NULL.
+   *
+   * @throws \InvalidArgumentException
+   *   If the given semantic version number does not contain a patch version.
+   */
+  protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease {
+    if (static::getPatchVersion($version) === NULL) {
+      throw new \InvalidArgumentException("The version number $version does not contain a patch version");
+    }
+    $releases = $this->getInstallableReleases();
+    foreach ($releases as $release) {
+      if (Semver::satisfies($release->getVersion(), "~$version")) {
+        return $release;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets the installed version of Drupal core.
+   *
+   * @return string
+   *   The installed version of Drupal core.
+   */
+  protected function getInstalledVersion(): string {
+    return $this->projectInfo->getInstalledVersion();
+  }
+
+  /**
+   * Gets the latest release in the currently installed minor.
+   *
+   * This will only return a release if it passes the ::isValidVersion() method
+   * of the version validator service injected into this class.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null
+   *   The latest release in the currently installed minor, if any, otherwise
+   *   NULL.
+   */
+  public function getLatestInInstalledMinor(): ?ProjectRelease {
+    return $this->getMostRecentReleaseInMinor($this->getInstalledVersion());
+  }
+
+  /**
+   * Gets the latest release in the next minor.
+   *
+   * This will only return a release if it passes the ::isValidVersion() method
+   * of the version validator service injected into this class.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null
+   *   The latest release in the next minor, if any, otherwise NULL.
+   */
+  public function getLatestInNextMinor(): ?ProjectRelease {
+    $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion());
+    $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0';
+    return $this->getMostRecentReleaseInMinor($next_minor);
+  }
+
+}
diff --git a/src/UpdateRecommender.php b/src/UpdateRecommender.php
deleted file mode 100644
index 7e6a2788eb..0000000000
--- a/src/UpdateRecommender.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-use Drupal\automatic_updates_9_3_shim\ProjectRelease;
-use Drupal\update\UpdateManagerInterface;
-
-/**
- * Determines the recommended release of Drupal core to update to.
- */
-class UpdateRecommender {
-
-  /**
-   * Returns up-to-date project information for Drupal core.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return array
-   *   The retrieved project information for Drupal core.
-   *
-   * @throws \RuntimeException
-   *   If data about available updates cannot be retrieved.
-   */
-  public function getProjectInfo(bool $refresh = FALSE): array {
-    $available_updates = update_get_available($refresh);
-    if (empty($available_updates)) {
-      throw new \RuntimeException('There was a problem getting update information. Try again later.');
-    }
-
-    $project_data = update_calculate_project_data($available_updates);
-    return $project_data['drupal'];
-  }
-
-  /**
-   * Returns the recommended release of Drupal core.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null
-   *   A value object with information about the recommended release, or NULL
-   *   if Drupal core is already up-to-date.
-   *
-   * @throws \LogicException
-   *   If Drupal core is out of date and the recommended version of cannot be
-   *   determined.
-   */
-  public function getRecommendedRelease(bool $refresh = FALSE): ?ProjectRelease {
-    $project = $this->getProjectInfo($refresh);
-
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($project['status'] === UpdateManagerInterface::CURRENT) {
-      return NULL;
-    }
-    // If we don't know what to recommend they update to, time to freak out.
-    elseif (empty($project['recommended'])) {
-      throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.');
-    }
-
-    $recommended_version = $project['recommended'];
-    return ProjectRelease::createFromArray($project['releases'][$recommended_version]);
-  }
-
-}
diff --git a/src/Validation/ReadinessValidationManager.php b/src/Validation/ReadinessValidationManager.php
index 0622c9defb..15ab0b3376 100644
--- a/src/Validation/ReadinessValidationManager.php
+++ b/src/Validation/ReadinessValidationManager.php
@@ -4,8 +4,8 @@ namespace Drupal\automatic_updates\Validation;
 
 use Drupal\automatic_updates\CronUpdater;
 use Drupal\automatic_updates\Event\ReadinessCheckEvent;
+use Drupal\automatic_updates\ProjectInfo;
 use Drupal\automatic_updates\Updater;
-use Drupal\automatic_updates\UpdateRecommender;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
@@ -102,20 +102,18 @@ class ReadinessValidationManager implements EventSubscriberInterface {
    * @return $this
    */
   public function run(): self {
-    $recommender = new UpdateRecommender();
-    $release = $recommender->getRecommendedRelease(TRUE);
     // If updates will run during cron, use the cron updater service provided by
     // this module. This will allow subscribers to ReadinessCheckEvent to run
     // specific validation for conditions that only affect cron updates.
-    if ($this->config->get('automatic_updates.settings')->get('cron') == CronUpdater::DISABLED) {
+    if ($this->config->get('automatic_updates.settings')->get('cron') === CronUpdater::DISABLED) {
       $stage = $this->updater;
     }
     else {
       $stage = $this->cronUpdater;
     }
-
-    $project_versions = $release ? ['drupal' => $release->getVersion()] : [];
-    $event = new ReadinessCheckEvent($stage, $project_versions);
+    $event = new ReadinessCheckEvent($stage);
+    // Version validators will need up-to-date project info.
+    (new ProjectInfo())->getProjectInfo(TRUE);
     $this->eventDispatcher->dispatch($event);
     $results = $event->getResults();
     $this->keyValueExpirable->setWithExpire(
diff --git a/src/Validator/CronUpdateVersionValidator.php b/src/Validator/CronUpdateVersionValidator.php
new file mode 100644
index 0000000000..f9070f8610
--- /dev/null
+++ b/src/Validator/CronUpdateVersionValidator.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator;
+
+use Drupal\automatic_updates\CronUpdater;
+use Drupal\automatic_updates\ProjectInfo;
+use Drupal\automatic_updates\VersionParsingTrait;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\package_manager\Stage;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * Validates the target version of Drupal core before a cron update.
+ *
+ * @internal
+ *   This class is an internal part of the module's cron update handling and
+ *   should not be used by external code.
+ */
+final class CronUpdateVersionValidator extends UpdateVersionValidator {
+
+  use VersionParsingTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function isStageSupported(Stage $stage): bool {
+    return $stage instanceof CronUpdater;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getValidationResult(string $to_version_string): ?ValidationResult {
+    if ($result = parent::getValidationResult($to_version_string)) {
+      return $result;
+    }
+    $from_version_string = $this->getCoreVersion();
+    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
+    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
+    $variables = [
+      '@to_version' => $to_version_string,
+      '@from_version' => $from_version_string,
+    ];
+    // @todo Return multiple validation messages and summary in
+    //   https://www.drupal.org/project/automatic_updates/issues/3272068.
+    // Validate that both the from and to versions are stable releases.
+    if ($from_version->getVersionExtra()) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables),
+      ]);
+    }
+    if ($to_version->getVersionExtra()) {
+      // Because we do not support updating to a new minor version during
+      // cron it is probably impossible to update from a stable version to
+      // a unstable/pre-release version, but we should check this condition
+      // just in case.
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables),
+      ]);
+    }
+
+    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables),
+      ]);
+    }
+
+    // Only updating to the next patch release is supported during cron.
+    $supported_patch_version = $from_version->getMajorVersion() . '.' . $from_version->getMinorVersion() . '.' . (((int) static::getPatchVersion($from_version_string)) + 1);
+    if ($to_version_string !== $supported_patch_version) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables),
+      ]);
+    }
+
+    // If both the from and to version numbers are valid check if the current
+    // settings only allow security updates during cron and if so ensure the
+    // update release is a security release.
+    $level = $this->configFactory->get('automatic_updates.settings')->get('cron');
+    if ($level === CronUpdater::SECURITY) {
+      $releases = (new ProjectInfo())->getInstallableReleases();
+      // @todo Remove this check and add validation to
+      //   \Drupal\automatic_updates\Validator\UpdateVersionValidator::getValidationResult()
+      //   to ensure the update release is always secure and supported in
+      //   https://www.drupal.org/i/3271468.
+      if (!isset($releases[$to_version_string])) {
+        return ValidationResult::createError([
+          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a valid release.', $variables),
+        ]);
+      }
+      if (!$releases[$to_version_string]->isSecurityRelease()) {
+        return ValidationResult::createError([
+          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a security release.', $variables),
+        ]);
+      }
+    }
+    return NULL;
+  }
+
+}
diff --git a/src/Validator/UpdateVersionValidator.php b/src/Validator/UpdateVersionValidator.php
index d867c393b7..50934ec0f6 100644
--- a/src/Validator/UpdateVersionValidator.php
+++ b/src/Validator/UpdateVersionValidator.php
@@ -2,20 +2,28 @@
 
 namespace Drupal\automatic_updates\Validator;
 
-use Composer\Semver\Semver;
+use Composer\Semver\Comparator;
 use Drupal\automatic_updates\CronUpdater;
 use Drupal\automatic_updates\Event\ReadinessCheckEvent;
+use Drupal\automatic_updates\ProjectInfo;
 use Drupal\automatic_updates\Updater;
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Stage;
+use Drupal\package_manager\ValidationResult;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
  * Validates that core updates are within a supported version range.
+ *
+ * @internal
+ *   This class is an internal part of the module's update handling and
+ *   should not be used by external code.
  */
 class UpdateVersionValidator implements EventSubscriberInterface {
 
@@ -48,12 +56,7 @@ class UpdateVersionValidator implements EventSubscriberInterface {
    *   The running core version as known to the Update module.
    */
   protected function getCoreVersion(): string {
-    // We need to call these functions separately, because
-    // update_get_available() will include the file that contains
-    // update_calculate_project_data().
-    $available_updates = update_get_available();
-    $available_updates = update_calculate_project_data($available_updates);
-    return $available_updates['drupal']['existing_version'];
+    return (new ProjectInfo())->getInstalledVersion();
   }
 
   /**
@@ -63,122 +66,141 @@ class UpdateVersionValidator implements EventSubscriberInterface {
    *   The event object.
    */
   public function checkUpdateVersion(PreOperationStageEvent $event): void {
-    $stage = $event->getStage();
-    // We only want to do this check if the stage belongs to Automatic Updates.
-    if (!$stage instanceof Updater) {
+    if (!static::isStageSupported($event->getStage())) {
       return;
     }
+    if ($to_version = $this->getUpdateVersion($event)) {
+      if ($result = $this->getValidationResult($to_version)) {
+        $event->addError($result->getMessages(), $result->getSummary());
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'checkUpdateVersion',
+      ReadinessCheckEvent::class => 'checkUpdateVersion',
+    ];
+  }
 
+  /**
+   * Gets the update version.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event.
+   *
+   * @return string|null
+   *   The version that the site will update to if any, otherwise NULL.
+   */
+  protected function getUpdateVersion(StageEvent $event): ?string {
+    /** @var \Drupal\automatic_updates\Updater $updater */
+    $updater = $event->getStage();
     if ($event instanceof ReadinessCheckEvent) {
       $package_versions = $event->getPackageVersions();
-      // During readiness checks, we might not know the desired package
-      // versions, which means there's nothing to validate.
-      if (empty($package_versions)) {
-        return;
-      }
     }
     else {
       // If the stage has begun its life cycle, we expect it knows the desired
       // package versions.
-      $package_versions = $stage->getPackageVersions()['production'];
+      $package_versions = $updater->getPackageVersions()['production'];
+    }
+    if ($package_versions) {
+      // All the core packages will be updated to the same version, so it
+      // doesn't matter which specific package we're looking at.
+      $core_package_name = key($updater->getActiveComposer()->getCorePackages());
+      return $package_versions[$core_package_name];
+    }
+    else {
+      // During readiness checks we might not have a version to update to. Check
+      // if there are any possible updates and add a message about why we cannot
+      // update to that version.
+      // @todo Remove this code in https://www.drupal.org/i/3272326 when we add
+      //   add a validator that will warn if cron updates will no longer work
+      //   because the site is more than 1 patch release behind.
+      $project_info = new ProjectInfo();
+      if ($possible_releases = $project_info->getInstallableReleases()) {
+        $possible_release = array_pop($possible_releases);
+        return $possible_release->getVersion();
+      }
     }
+    return NULL;
+  }
 
+  /**
+   * Determines if a version is valid.
+   *
+   * @param string $version
+   *   The version string.
+   *
+   * @return bool
+   *   TRUE if the version is valid (i.e., the site can update to it), otherwise
+   *   FALSE.
+   */
+  public function isValidVersion(string $version): bool {
+    return empty($this->getValidationResult($version));
+  }
+
+  /**
+   * Validates if an update to a specific version is allowed.
+   *
+   * @param string $to_version_string
+   *   The version to update to.
+   *
+   * @return \Drupal\package_manager\ValidationResult|null
+   *   NULL if the update is allowed, otherwise returns a validation result with
+   *   the reason why the update is not allowed.
+   */
+  protected function getValidationResult(string $to_version_string): ?ValidationResult {
     $from_version_string = $this->getCoreVersion();
-    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
-    // All the core packages will be updated to the same version, so it doesn't
-    // matter which specific package we're looking at.
-    $core_package_name = key($stage->getActiveComposer()->getCorePackages());
-    $to_version_string = $package_versions[$core_package_name];
-    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
     $variables = [
       '@to_version' => $to_version_string,
       '@from_version' => $from_version_string,
     ];
-    $from_version_extra = $from_version->getVersionExtra();
-    $to_version_extra = $to_version->getVersionExtra();
-    if (Semver::satisfies($to_version_string, "< $from_version_string")) {
-      $event->addError([
-        $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables),
+    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
+    // @todo Return multiple validation messages and summary in
+    //   https://www.drupal.org/project/automatic_updates/issues/3272068.
+    if ($from_version->getVersionExtra() === 'dev') {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables),
       ]);
     }
-    elseif ($from_version_extra === 'dev') {
-      $event->addError([
-        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables),
+    if (Comparator::lessThan($to_version_string, $from_version_string)) {
+      return ValidationResult::createError([
+        $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables),
       ]);
     }
-    elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
-      $event->addError([
+    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
+    if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
+      return ValidationResult::createError([
         $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one major version to another are not supported.', $variables),
       ]);
     }
-    elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
       if (!$this->configFactory->get('automatic_updates.settings')->get('allow_core_minor_updates')) {
-        $event->addError([
+        return ValidationResult::createError([
           $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported.', $variables),
         ]);
       }
-      elseif ($stage instanceof CronUpdater) {
-        $event->addError([
-          $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables),
-        ]);
-      }
-    }
-    elseif ($stage instanceof CronUpdater) {
-      if ($from_version_extra || $to_version_extra) {
-        if ($from_version_extra) {
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables);
-          $event->addError($messages);
-        }
-        if ($to_version_extra) {
-          // Because we do not support updating to a new minor version during
-          // cron it is probably impossible to update from a stable version to
-          // a unstable/pre-release version, but we should check this condition
-          // just in case.
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables);
-          $event->addError($messages);
-        }
-      }
-      else {
-        $to_patch_version = (int) $this->getPatchVersion($to_version_string);
-        $from_patch_version = (int) $this->getPatchVersion($from_version_string);
-        if ($from_patch_version + 1 !== $to_patch_version) {
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables);
-          $event->addError($messages);
-        }
-      }
     }
+    return NULL;
   }
 
   /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'checkUpdateVersion',
-      ReadinessCheckEvent::class => 'checkUpdateVersion',
-    ];
-  }
-
-  /**
-   * Gets the patch number for a version string.
+   * Determines if a stage is supported by this validator.
    *
-   * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in
-   *   https://www.drupal.org/i/3261744.
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage to check.
    *
-   * @param string $version_string
-   *   The version string.
-   *
-   * @return string
-   *   The patch number.
+   * @return bool
+   *   TRUE if the stage is supported by this validator, otherwise FALSE.
    */
-  private function getPatchVersion(string $version_string): string {
-    $version_extra = ExtensionVersion::createFromVersionString($version_string)
-      ->getVersionExtra();
-    if ($version_extra) {
-      $version_string = str_replace("-$version_extra", '', $version_string);
-    }
-    $version_parts = explode('.', $version_string);
-    return $version_parts[2];
+  protected static function isStageSupported(Stage $stage): bool {
+    // We only want to do this check if the stage belongs to Automatic Updates,
+    // and it is not a cron update.
+    // @see \Drupal\automatic_updates\Validator\CronUpdateVersionValidator
+    return $stage instanceof Updater && !$stage instanceof CronUpdater;
   }
 
 }
diff --git a/src/VersionParsingTrait.php b/src/VersionParsingTrait.php
new file mode 100644
index 0000000000..c8d52f8e25
--- /dev/null
+++ b/src/VersionParsingTrait.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Drupal\Core\Extension\ExtensionVersion;
+
+/**
+ * Common function for parsing version traits.
+ *
+ * @internal
+ *   This trait may be removed in patch or minor versions.
+ */
+trait VersionParsingTrait {
+
+  /**
+   * Gets the patch number from a version string.
+   *
+   * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in
+   *   https://www.drupal.org/i/3261744.
+   *
+   * @param string $version_string
+   *   The version string.
+   *
+   * @return string|null
+   *   The patch number if available, otherwise NULL.
+   */
+  protected static function getPatchVersion(string $version_string): ?string {
+    $version_extra = ExtensionVersion::createFromVersionString($version_string)
+      ->getVersionExtra();
+    if ($version_extra) {
+      $version_string = str_replace("-$version_extra", '', $version_string);
+    }
+    $version_parts = explode('.', $version_string);
+    return count($version_parts) === 3 ? $version_parts[2] : NULL;
+  }
+
+}
diff --git a/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
new file mode 100644
index 0000000000..cc65b78a69
--- /dev/null
+++ b/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>9.7.,9.8.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+  <terms>
+   <term><name>Projects</name><value>Drupal project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>Drupal 9.8.2</name>
+    <version>9.8.2</version>
+    <status>published</status>
+    <release_link>http://example.com/drupal-9-8-2-release</release_link>
+    <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+    <date>1250425521</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>Drupal 9.8.1</name>
+  <version>9.8.1</version>
+  <status>published</status>
+  <release_link>http://example.com/drupal-9-8-1-release</release_link>
+  <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+  <date>1250425521</date>
+  <terms>
+    <term><name>Release type</name><value>New features</value></term>
+    <term><name>Release type</name><value>Bug fixes</value></term>
+    <term><name>Release type</name><value>Security update</value></term>
+  </terms>
+ </release>
+ <release>
+   <name>Drupal 9.8.0</name>
+   <version>9.8.0</version>
+   <status>published</status>
+   <release_link>http://example.com/drupal-9-8-0-release</release_link>
+   <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
+   <date>1250424521</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+     <term><name>Release type</name><value>Insecure</value></term>
+   </terms>
+ </release>
+  <release>
+    <name>Drupal 9.8.0-alpha1</name>
+    <version>9.8.0-alpha1</version>
+    <status>published</status>
+    <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+    <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+    <date>1250424521</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>Drupal 9.7.1</name>
+        <version>9.7.1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+        <date>1250425521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+            <term><name>Release type</name><value>Security update</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0</name>
+        <version>9.7.0</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+            <term><name>Release type</name><value>Insecure</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0-alpha1</name>
+        <version>9.7.0-alpha1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+        <date>1250424521</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/tests/fixtures/release-history/drupal.9.8.2.xml b/tests/fixtures/release-history/drupal.9.8.2.xml
index 7bb2c1a112..2de1077e5e 100644
--- a/tests/fixtures/release-history/drupal.9.8.2.xml
+++ b/tests/fixtures/release-history/drupal.9.8.2.xml
@@ -3,7 +3,7 @@
 <title>Drupal</title>
 <short_name>drupal</short_name>
 <dc:creator>Drupal</dc:creator>
-<supported_branches>9.8.</supported_branches>
+<supported_branches>9.7.,9.8.</supported_branches>
 <project_status>published</project_status>
 <link>http://example.com/project/drupal</link>
   <terms>
@@ -58,5 +58,41 @@
       <term><name>Release type</name><value>Bug fixes</value></term>
     </terms>
   </release>
+    <release>
+        <name>Drupal 9.7.1</name>
+        <version>9.7.1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+        <date>1250425521</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>Drupal 9.7.0</name>
+        <version>9.7.0</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+        <date>1250424521</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>Drupal 9.7.0-alpha1</name>
+        <version>9.7.0-alpha1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+        <date>1250424521</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/tests/src/Functional/ReadinessValidationTest.php b/tests/src/Functional/ReadinessValidationTest.php
index c5b4fe525e..96eab4ae97 100644
--- a/tests/src/Functional/ReadinessValidationTest.php
+++ b/tests/src/Functional/ReadinessValidationTest.php
@@ -54,7 +54,7 @@ class ReadinessValidationTest extends AutomaticUpdatesFunctionalTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml');
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
     $this->setCoreVersion('9.8.1');
 
     $this->reportViewerUser = $this->createUser([
diff --git a/tests/src/Functional/UpdateLockTest.php b/tests/src/Functional/UpdateLockTest.php
index 1fbf8532a6..2c41af418d 100644
--- a/tests/src/Functional/UpdateLockTest.php
+++ b/tests/src/Functional/UpdateLockTest.php
@@ -56,7 +56,7 @@ class UpdateLockTest extends AutomaticUpdatesFunctionalTestBase {
     $this->checkForMetaRefresh();
     $this->assertUpdateReady('9.8.1');
     $assert_session->buttonExists('Continue');
-    $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH);
+    $url = $this->getSession()->getCurrentUrl();
 
     // Another user cannot show up and try to start an update, since the other
     // user already started one.
diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php
index 4c07670396..e9a56e7d60 100644
--- a/tests/src/Kernel/CronUpdaterTest.php
+++ b/tests/src/Kernel/CronUpdaterTest.php
@@ -110,7 +110,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
       'enabled, normal release' => [
         CronUpdater::ALL,
         "$fixture_dir/drupal.9.8.2.xml",
-        TRUE,
+        FALSE,
       ],
       'enabled, security release' => [
         CronUpdater::ALL,
diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
index 9cf626c750..c9a09cb821 100644
--- a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
+++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -30,6 +30,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
+    $this->setCoreVersion('9.8.2');
     $this->installEntitySchema('user');
     $this->installSchema('user', ['users_data']);
     $this->createTestValidationResults();
@@ -222,6 +223,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase {
       ->install(['automatic_updates']);
 
     // Ensure there's a simulated core release to update to.
+    $this->setCoreVersion('9.8.1');
     $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.2.xml');
 
     // The readiness checker should raise a warning, so that the update is not
@@ -246,7 +248,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase {
 
     /** @var \Drupal\automatic_updates\Updater $updater */
     $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
+    $updater->begin(['drupal' => '9.8.2']);
     $updater->stage();
     $updater->apply();
     $updater->destroy();
diff --git a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
index 45cabcfb6b..720f27707b 100644
--- a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
+++ b/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
@@ -3,11 +3,8 @@
 namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
 
 use Drupal\automatic_updates\CronUpdater;
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-use Drupal\Tests\automatic_updates\Kernel\TestCronUpdater;
 use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
 use Psr\Log\Test\TestLogger;
 
@@ -167,11 +164,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
     // the update shouldn't have been started.
     elseif ($expected_results) {
       $this->assertUpdateStagedTimes(0);
-
-      // An exception exactly like this one should have been thrown by
-      // CronUpdater::dispatch(), and subsequently caught, formatted as HTML,
-      // and logged.
-      $this->assertErrorsWereLogged($expected_results);
     }
     // If cron updates are enabled and no validation errors were expected, the
     // update should have started and nothing should have been logged.
@@ -197,8 +189,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
         CronUpdater::DISABLED,
         [],
       ],
-      // The latest release is two patch releases ahead, so the update should be
-      // blocked even though the cron configuration allows it.
       'security only' => [
         CronUpdater::SECURITY,
         [$update_disallowed],
@@ -230,15 +220,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
     $this->assertCheckerResultsFromManager($expected_results, TRUE);
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes(0);
-
-    // If cron updates are enabled for all patch releases, the error should have
-    // been raised and logged.
-    if ($cron_setting === CronUpdater::ALL) {
-      $this->assertErrorsWereLogged($expected_results);
-    }
-    else {
-      $this->assertArrayNotHasKey(RfcLogLevel::ERROR, $this->logger->recordsByLevel);
-    }
   }
 
   /**
@@ -280,7 +261,13 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
     $this->config('automatic_updates.settings')
       ->set('cron', $cron_setting)
       ->save();
-    $this->assertCheckerResultsFromManager([], TRUE);
+    if ($cron_setting === CronUpdater::SECURITY) {
+      $expected_result = ValidationResult::createError(['Drupal cannot be automatically updated during cron from its current version, 9.8.1, to the recommended version, 9.8.2, because 9.8.2 is not a security release.']);
+      $this->assertCheckerResultsFromManager([$expected_result], TRUE);
+    }
+    else {
+      $this->assertCheckerResultsFromManager([], TRUE);
+    }
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes((int) $will_update);
   }
@@ -298,9 +285,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
     $dev_current_version = ValidationResult::createError([
       'Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.2, because automatic updates from a dev version to any other version are not supported.',
     ]);
-    $newer_current_version = ValidationResult::createError([
-      'Update version 9.8.2 is lower than 9.8.3, downgrading is not supported.',
-    ]);
     $different_major_version = ValidationResult::createError([
       'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.2, because automatic updates from one major version to another are not supported.',
     ]);
@@ -313,81 +297,46 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
         // the validation will be run with the regular updater, not the cron
         // updater.
         [],
-        [],
       ],
       'unstable current version, security updates allowed' => [
         CronUpdater::SECURITY,
         '9.8.0-alpha1',
         [$unstable_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'unstable current version, all updates allowed' => [
         CronUpdater::ALL,
         '9.8.0-alpha1',
         [$unstable_current_version],
-        [$unstable_current_version],
       ],
       'dev current version, cron disabled' => [
         CronUpdater::DISABLED,
         '9.8.0-dev',
         [$dev_current_version],
-        [],
       ],
       'dev current version, security updates allowed' => [
         CronUpdater::SECURITY,
         '9.8.0-dev',
         [$dev_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'dev current version, all updates allowed' => [
         CronUpdater::ALL,
         '9.8.0-dev',
         [$dev_current_version],
-        [$dev_current_version],
-      ],
-      'newer current version, cron disabled' => [
-        CronUpdater::DISABLED,
-        '9.8.3',
-        [$newer_current_version],
-        [],
-      ],
-      'newer current version, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '9.8.3',
-        [$newer_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
-      ],
-      'newer current version, all updates allowed' => [
-        CronUpdater::ALL,
-        '9.8.3',
-        [$newer_current_version],
-        [$newer_current_version],
       ],
       'different current major, cron disabled' => [
         CronUpdater::DISABLED,
         '8.9.1',
         [$different_major_version],
-        [],
       ],
       'different current major, security updates allowed' => [
         CronUpdater::SECURITY,
         '8.9.1',
         [$different_major_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'different current major, all updates allowed' => [
         CronUpdater::ALL,
         '8.9.1',
         [$different_major_version],
-        [$different_major_version],
       ],
     ];
   }
@@ -402,12 +351,10 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
    * @param \Drupal\package_manager\ValidationResult[] $expected_results
    *   The validation results, if any, that should be flagged during readiness
    *   checks.
-   * @param \Drupal\package_manager\ValidationResult[] $logged_results
-   *   The validation results, if any, that should be logged when cron is run.
    *
    * @dataProvider providerInvalidCronUpdate
    */
-  public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results, array $logged_results): void {
+  public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results): void {
     $this->setCoreVersion($current_core_version);
     $this->config('automatic_updates.settings')
       ->set('cron', $cron_setting)
@@ -423,23 +370,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
     // created (in which case, we expect the errors to be logged).
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes(0);
-    if ($logged_results) {
-      $this->assertErrorsWereLogged($logged_results);
-    }
-  }
-
-  /**
-   * Asserts that validation errors were logged during a cron update.
-   *
-   * @param \Drupal\package_manager\ValidationResult[] $results
-   *   The validation errors should have been logged.
-   */
-  private function assertErrorsWereLogged(array $results): void {
-    $exception = new StageValidationException($results, 'Unable to complete the update because of errors.');
-    // The exception will be formatted in a specific, predictable way.
-    // @see \Drupal\Tests\automatic_updates\Kernel\CronUpdaterTest::testErrors()
-    $message = TestCronUpdater::formatValidationException($exception);
-    $this->assertTrue($this->logger->hasRecord($message, RfcLogLevel::ERROR));
   }
 
 }
diff --git a/tests/src/Kernel/ReleaseChooserTest.php b/tests/src/Kernel/ReleaseChooserTest.php
new file mode 100644
index 0000000000..dac21432ae
--- /dev/null
+++ b/tests/src/Kernel/ReleaseChooserTest.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\ReleaseChooser
+ *
+ * @group automatic_updates
+ */
+class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2-older-sec-release.xml');
+
+  }
+
+  /**
+   * Data provider for testReleases().
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerReleases(): array {
+    return [
+      'installed 9.8.0, no minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.2',
+        'next_minor' => NULL,
+      ],
+      'installed 9.8.0, minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.2',
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.0, no minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.0, minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => '9.8.2',
+      ],
+      'installed 9.7.2, no minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.2, minor support' => [
+        'chooser' => 'automatic_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => '9.8.2',
+      ],
+      'cron, installed 9.8.0, no minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.8.0, minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.0, no minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.0, minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.2, no minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.2, minor support' => [
+        'chooser' => 'automatic_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+    ];
+  }
+
+  /**
+   * Tests fetching the recommended release when an update is available.
+   *
+   * @param string $chooser_service
+   *   The ID of release chooser service to use.
+   * @param bool $minor_support
+   *   Whether updates to the next minor will be allowed.
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $current_minor
+   *   The expected release in the currently installed minor or NULL if none is
+   *   available.
+   * @param string|null $next_minor
+   *   The expected release in the next minor or NULL if none is available.
+   *
+   * @dataProvider providerReleases
+   *
+   * @covers ::getLatestInInstalledMinor
+   * @covers ::getLatestInNextMinor
+   */
+  public function testReleases(string $chooser_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void {
+    $this->setCoreVersion($installed_version);
+    $this->config('automatic_updates.settings')->set('allow_core_minor_updates', $minor_support)->save();
+    /** @var \Drupal\automatic_updates\ReleaseChooser $chooser */
+    $chooser = $this->container->get($chooser_service);
+    $chooser->refresh();
+    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor());
+    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor());
+  }
+
+  /**
+   * Asserts that a project release matches a version number.
+   *
+   * @param string|null $version
+   *   The version to check, or NULL if no version expected.
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease|null $release
+   *   The release to check, or NULL if no release is expected.
+   */
+  private function assertReleaseVersion(?string $version, ?ProjectRelease $release) {
+    if (is_null($version)) {
+      $this->assertNull($release);
+    }
+    else {
+      $this->assertNotEmpty($release);
+      $this->assertSame($version, $release->getVersion());
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/UpdateRecommenderTest.php b/tests/src/Kernel/UpdateRecommenderTest.php
deleted file mode 100644
index be699afe00..0000000000
--- a/tests/src/Kernel/UpdateRecommenderTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel;
-
-use Drupal\automatic_updates\UpdateRecommender;
-
-/**
- * @covers \Drupal\automatic_updates\UpdateRecommender
- *
- * @group automatic_updates
- */
-class UpdateRecommenderTest extends AutomaticUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'automatic_updates',
-    'package_manager',
-  ];
-
-  /**
-   * Tests fetching the recommended release when an update is available.
-   */
-  public function testUpdateAvailable(): void {
-    $recommender = new UpdateRecommender();
-    $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    $this->assertNotEmpty($recommended_release);
-    $this->assertSame('9.8.2', $recommended_release->getVersion());
-    // Getting the recommended release again should not trigger another request.
-    $this->assertNotEmpty($recommender->getRecommendedRelease());
-  }
-
-  /**
-   * Tests fetching the recommended release when there is no update available.
-   */
-  public function testNoUpdateAvailable(): void {
-    $this->setCoreVersion('9.8.2');
-
-    $recommender = new UpdateRecommender();
-    $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    $this->assertNull($recommended_release);
-    // Getting the recommended release again should not trigger another request.
-    $this->assertNull($recommender->getRecommendedRelease());
-  }
-
-}
diff --git a/tests/src/Unit/ProjectInfoTest.php b/tests/src/Unit/ProjectInfoTest.php
new file mode 100644
index 0000000000..0ba190765f
--- /dev/null
+++ b/tests/src/Unit/ProjectInfoTest.php
@@ -0,0 +1,194 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit;
+
+use Drupal\automatic_updates\ProjectInfo;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\Tests\UnitTestCase;
+use Drupal\update\UpdateManagerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\ProjectInfo
+ *
+ * @group automatic_updates
+ */
+class ProjectInfoTest extends UnitTestCase {
+
+  /**
+   * Creates release data for testing.
+   *
+   * @return string[][]
+   *   The release information.
+   */
+  private static function createTestReleases(): array {
+    $versions = ['8.2.5', '8.2.4', '8.2.3', '8.2.3-alpha'];
+    foreach ($versions as $version) {
+      $release_arrays[$version] = [
+        'status' => 'published',
+        'version' => $version,
+        'release_link' => "https://example.drupal.org/project/drupal/releases/$version",
+      ];
+    }
+    return $release_arrays;
+  }
+
+  /**
+   * Data provider for testGetInstallableReleases().
+   *
+   * @return array[][]
+   *   The test cases.
+   */
+  public function providerGetInstallableReleases(): array {
+    $release_arrays = static::createTestReleases();
+    foreach ($release_arrays as $version => $release_array) {
+      $release_objects[$version] = ProjectRelease::createFromArray($release_array);
+    }
+    return [
+      'current' => [
+        [
+          'status' => UpdateManagerInterface::CURRENT,
+          'existing_version' => '1.2.3',
+        ],
+        [],
+      ],
+      '1 release' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.4',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+        ],
+      ],
+      '1 releases, also security' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.4',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            $release_arrays['8.2.5'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+        ],
+      ],
+      '1 release, other security' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.2',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            // Set out of order security releases to ensure results are sorted.
+            $release_arrays['8.2.3-alpha'],
+            $release_arrays['8.2.3'],
+            $release_arrays['8.2.4'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+          '8.2.4' => $release_objects['8.2.4'],
+          '8.2.3' => $release_objects['8.2.3'],
+          '8.2.3-alpha' => $release_objects['8.2.3-alpha'],
+        ],
+      ],
+      '1 releases, other security lower than current version' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.3',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            // Set out of order security releases to ensure results are sorted.
+            $release_arrays['8.2.3-alpha'],
+            $release_arrays['8.2.3'],
+            $release_arrays['8.2.4'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+          '8.2.4' => $release_objects['8.2.4'],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::getInstallableReleases
+   *
+   * @param array $project_data
+   *   The project data to return from ::getProjectInfo().
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $expected_releases
+   *   The expected releases.
+   *
+   * @dataProvider providerGetInstallableReleases
+   */
+  public function testGetInstallableReleases(array $project_data, array $expected_releases): void {
+    $project_info = $this->getMockedProjectInfo($project_data);
+    $this->assertEqualsCanonicalizing($expected_releases, $project_info->getInstallableReleases());
+  }
+
+  /**
+   * @covers ::getInstallableReleases
+   */
+  public function testInvalidProjectData(): void {
+    $release_arrays = static::createTestReleases();
+    $project_data = [
+      'status' => UpdateManagerInterface::NOT_CURRENT,
+      'existing_version' => '1.2.3',
+      'releases' => [
+        '8.2.5' => $release_arrays['8.2.5'],
+      ],
+      'security updates' => [
+        $release_arrays['8.2.4'],
+        $release_arrays['8.2.3'],
+        $release_arrays['8.2.3-alpha'],
+      ],
+    ];
+    $project_info = $this->getMockedProjectInfo($project_data);
+    $this->expectException('LogicException');
+    $this->expectExceptionMessage('Drupal core is out of date, but the recommended version could not be determined.');
+    $project_info->getInstallableReleases();
+  }
+
+  /**
+   * @covers ::getInstalledVersion
+   */
+  public function testGetInstalledVersion(): void {
+    $project_info = $this->getMockedProjectInfo(['existing_version' => '1.2.3']);
+    $this->assertSame('1.2.3', $project_info->getInstalledVersion());
+  }
+
+  /**
+   * Mocks a ProjectInfo object.
+   *
+   * @param array $project_data
+   *   The project info that should be returned by the mock's ::getProjectInfo()
+   *   method.
+   *
+   * @return \Drupal\automatic_updates\ProjectInfo
+   *   The mocked object.
+   */
+  private function getMockedProjectInfo(array $project_data): ProjectInfo {
+    $project_info = $this->getMockBuilder(ProjectInfo::class)
+      ->onlyMethods(['getProjectInfo'])
+      ->getMock();
+    $project_info->expects($this->any())
+      ->method('getProjectInfo')
+      ->willReturn($project_data);
+    return $project_info;
+  }
+
+}
-- 
GitLab