From d90ebb937aae83c04c2d2317b3dbaeb7b892646b Mon Sep 17 00:00:00 2001
From: phenaproxima <phenaproxima@205645.no-reply.drupal.org>
Date: Fri, 20 May 2022 14:26:52 +0000
Subject: [PATCH] Issue #3280180 by phenaproxima, tedbow: Consolidate all
 version validation logic into a single class

---
 automatic_updates.services.yml                |  32 +-
 src/CronUpdater.php                           |   2 +-
 src/Form/UpdaterForm.php                      |   4 +-
 src/ReleaseChooser.php                        |  48 ++-
 src/Validator/CronUpdateVersionValidator.php  | 124 ------
 src/Validator/InstalledVersionValidator.php   |  59 ---
 src/Validator/UpdateReleaseValidator.php      |  60 ---
 src/Validator/UpdateVersionValidator.php      | 215 ----------
 .../VersionPolicy/ForbidDevSnapshot.php       |  43 ++
 .../VersionPolicy/ForbidDowngrade.php         |  43 ++
 .../VersionPolicy/ForbidMinorUpdates.php      |  47 +++
 .../VersionPolicy/MajorVersionMatch.php       |  48 +++
 .../VersionPolicy/MinorUpdatesEnabled.php     |  86 ++++
 .../VersionPolicy/StableReleaseInstalled.php  |  43 ++
 .../VersionPolicy/TargetSecurityRelease.php   |  44 ++
 .../TargetVersionInstallable.php              |  45 +++
 .../VersionPolicy/TargetVersionStable.php     |  45 +++
 src/Validator/VersionPolicyValidator.php      | 259 ++++++++++++
 src/VersionParsingTrait.php                   |  15 +
 .../drupal.9.8.2-older-sec-release.xml        |  13 +
 .../Kernel/AutomaticUpdatesKernelTestBase.php |   4 +-
 tests/src/Kernel/ProjectInfoTest.php          |   2 +-
 .../CronUpdateVersionValidatorTest.php        |  77 ----
 .../InstalledVersionValidatorTest.php         |  38 --
 .../UpdateReleaseValidatorTest.php            |  40 --
 .../UpdateVersionValidatorTest.php            | 332 ---------------
 .../VersionPolicyValidatorTest.php            | 377 ++++++++++++++++++
 tests/src/Kernel/ReleaseChooserTest.php       |  42 +-
 tests/src/Traits/VersionPolicyTestTrait.php   |  32 ++
 .../VersionPolicy/ForbidDevSnapshotTest.php   |  64 +++
 .../VersionPolicy/ForbidDowngradeTest.php     |  66 +++
 .../VersionPolicy/ForbidMinorUpdatesTest.php  |  91 +++++
 .../VersionPolicy/MajorVersionMatchTest.php   |  81 ++++
 .../VersionPolicy/MinorUpdatesEnabledTest.php | 137 +++++++
 .../StableReleaseInstalledTest.php            |  60 +++
 .../TargetSecurityReleaseTest.php             |  68 ++++
 .../TargetVersionInstallableTest.php          |  69 ++++
 .../VersionPolicy/TargetVersionStableTest.php |  64 +++
 38 files changed, 1905 insertions(+), 1014 deletions(-)
 delete mode 100644 src/Validator/CronUpdateVersionValidator.php
 delete mode 100644 src/Validator/InstalledVersionValidator.php
 delete mode 100644 src/Validator/UpdateReleaseValidator.php
 delete mode 100644 src/Validator/UpdateVersionValidator.php
 create mode 100644 src/Validator/VersionPolicy/ForbidDevSnapshot.php
 create mode 100644 src/Validator/VersionPolicy/ForbidDowngrade.php
 create mode 100644 src/Validator/VersionPolicy/ForbidMinorUpdates.php
 create mode 100644 src/Validator/VersionPolicy/MajorVersionMatch.php
 create mode 100644 src/Validator/VersionPolicy/MinorUpdatesEnabled.php
 create mode 100644 src/Validator/VersionPolicy/StableReleaseInstalled.php
 create mode 100644 src/Validator/VersionPolicy/TargetSecurityRelease.php
 create mode 100644 src/Validator/VersionPolicy/TargetVersionInstallable.php
 create mode 100644 src/Validator/VersionPolicy/TargetVersionStable.php
 create mode 100644 src/Validator/VersionPolicyValidator.php
 delete mode 100644 tests/src/Kernel/ReadinessValidation/CronUpdateVersionValidatorTest.php
 delete mode 100644 tests/src/Kernel/ReadinessValidation/InstalledVersionValidatorTest.php
 delete mode 100644 tests/src/Kernel/ReadinessValidation/UpdateReleaseValidatorTest.php
 delete mode 100644 tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
 create mode 100644 tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
 create mode 100644 tests/src/Traits/VersionPolicyTestTrait.php
 create mode 100644 tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
 create mode 100644 tests/src/Unit/VersionPolicy/TargetVersionStableTest.php

diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index bf3916ff1c..a5e5545c3a 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -29,7 +29,7 @@ services:
   automatic_updates.cron_updater:
     class: Drupal\automatic_updates\CronUpdater
     arguments:
-      - '@automatic_updates.cron_release_chooser'
+      - '@automatic_updates.release_chooser'
       - '@logger.factory'
       - '@config.factory'
       - '@package_manager.path_locator'
@@ -52,28 +52,10 @@ services:
       - '@string_translation'
     tags:
       - { name: event_subscriber }
-  automatic_updates.update_version_validator:
-    class: Drupal\automatic_updates\Validator\UpdateVersionValidator
-    arguments:
-      - '@string_translation'
-      - '@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.validator.version_policy'
   automatic_updates.composer_executable_validator:
     class: Drupal\automatic_updates\Validator\PackageManagerReadinessCheck
     arguments:
@@ -133,12 +115,10 @@ services:
     class: Drupal\automatic_updates\Validator\XdebugValidator
     tags:
       - { name: event_subscriber }
-  automatic_updates.validator.installed_version:
-    class: Drupal\automatic_updates\Validator\InstalledVersionValidator
-    tags:
-      - { name: event_subscriber }
-  automatic_updates.validator.target_release:
-    class: Drupal\automatic_updates\Validator\UpdateReleaseValidator
+  automatic_updates.validator.version_policy:
+    class: Drupal\automatic_updates\Validator\VersionPolicyValidator
+    arguments:
+      - '@class_resolver'
     tags:
       - { name: event_subscriber }
   automatic_updates.config_subscriber:
diff --git a/src/CronUpdater.php b/src/CronUpdater.php
index e6a9dc4573..6f07e0255e 100644
--- a/src/CronUpdater.php
+++ b/src/CronUpdater.php
@@ -87,7 +87,7 @@ class CronUpdater extends Updater {
       return;
     }
 
-    $next_release = $this->releaseChooser->getLatestInInstalledMinor();
+    $next_release = $this->releaseChooser->getLatestInInstalledMinor($this);
     if ($next_release) {
       $this->performUpdate($next_release->getVersion(), $timeout);
     }
diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php
index 0de0dd7309..c44d057d91 100644
--- a/src/Form/UpdaterForm.php
+++ b/src/Form/UpdaterForm.php
@@ -139,9 +139,9 @@ class UpdaterForm extends FormBase {
       //   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.
-      $recommended_release = $this->releaseChooser->getLatestInInstalledMinor();
+      $recommended_release = $this->releaseChooser->getLatestInInstalledMinor($this->updater);
       if (!$recommended_release) {
-        $recommended_release = $this->releaseChooser->getLatestInNextMinor();
+        $recommended_release = $this->releaseChooser->getLatestInNextMinor($this->updater);
       }
     }
     catch (\RuntimeException $e) {
diff --git a/src/ReleaseChooser.php b/src/ReleaseChooser.php
index 2725bab4c5..0aad2c2c60 100644
--- a/src/ReleaseChooser.php
+++ b/src/ReleaseChooser.php
@@ -3,7 +3,7 @@
 namespace Drupal\automatic_updates;
 
 use Composer\Semver\Semver;
-use Drupal\automatic_updates\Validator\UpdateVersionValidator;
+use Drupal\automatic_updates\Validator\VersionPolicyValidator;
 use Drupal\automatic_updates_9_3_shim\ProjectRelease;
 use Drupal\Core\Extension\ExtensionVersion;
 
@@ -15,11 +15,11 @@ class ReleaseChooser {
   use VersionParsingTrait;
 
   /**
-   * The version validator service.
+   * The version policy validator service.
    *
-   * @var \Drupal\automatic_updates\Validator\UpdateVersionValidator
+   * @var \Drupal\automatic_updates\Validator\VersionPolicyValidator
    */
-  protected $versionValidator;
+  protected $versionPolicyValidator;
 
   /**
    * The project information fetcher.
@@ -31,25 +31,31 @@ class ReleaseChooser {
   /**
    * Constructs an ReleaseChooser object.
    *
-   * @param \Drupal\automatic_updates\Validator\UpdateVersionValidator $version_validator
+   * @param \Drupal\automatic_updates\Validator\VersionPolicyValidator $version_policy_validator
    *   The version validator.
    */
-  public function __construct(UpdateVersionValidator $version_validator) {
-    $this->versionValidator = $version_validator;
+  public function __construct(VersionPolicyValidator $version_policy_validator) {
+    $this->versionPolicyValidator = $version_policy_validator;
     $this->projectInfo = new ProjectInfo('drupal');
   }
 
   /**
    * Returns the releases that are installable.
    *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater that will be used to install the releases.
+   *
    * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[]
-   *   The releases that are installable according to the version validator
-   *   service.
+   *   The releases that are installable by the given updtaer, according to the
+   *   version validator service.
    */
-  protected function getInstallableReleases(): array {
+  protected function getInstallableReleases(Updater $updater): array {
+    $filter = function (string $version) use ($updater): bool {
+      return empty($this->versionPolicyValidator->validateVersion($updater, $version));
+    };
     return array_filter(
       $this->projectInfo->getInstallableReleases(),
-      [$this->versionValidator, 'isValidVersion'],
+      $filter,
       ARRAY_FILTER_USE_KEY
     );
   }
@@ -57,6 +63,8 @@ class ReleaseChooser {
   /**
    * Gets the most recent release in the same minor as a specified version.
    *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater that will be used to install the release.
    * @param string $version
    *   The full semantic version number, which must include a patch version.
    *
@@ -66,11 +74,11 @@ class ReleaseChooser {
    * @throws \InvalidArgumentException
    *   If the given semantic version number does not contain a patch version.
    */
-  protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease {
+  protected function getMostRecentReleaseInMinor(Updater $updater, string $version): ?ProjectRelease {
     if (static::getPatchVersion($version) === NULL) {
       throw new \InvalidArgumentException("The version number $version does not contain a patch version");
     }
-    $releases = $this->getInstallableReleases();
+    $releases = $this->getInstallableReleases($updater);
     foreach ($releases as $release) {
       // Checks if the release is in the same minor as the currently installed
       // version. For example, if the current version is 9.8.0 then the
@@ -99,12 +107,15 @@ class ReleaseChooser {
    * This will only return a release if it passes the ::isValidVersion() method
    * of the version validator service injected into this class.
    *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater which will install the release.
+   *
    * @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());
+  public function getLatestInInstalledMinor(Updater $updater): ?ProjectRelease {
+    return $this->getMostRecentReleaseInMinor($updater, $this->getInstalledVersion());
   }
 
   /**
@@ -113,13 +124,16 @@ class ReleaseChooser {
    * This will only return a release if it passes the ::isValidVersion() method
    * of the version validator service injected into this class.
    *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater which will install the release.
+   *
    * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null
    *   The latest release in the next minor, if any, otherwise NULL.
    */
-  public function getLatestInNextMinor(): ?ProjectRelease {
+  public function getLatestInNextMinor(Updater $updater): ?ProjectRelease {
     $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion());
     $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0';
-    return $this->getMostRecentReleaseInMinor($next_minor);
+    return $this->getMostRecentReleaseInMinor($updater, $next_minor);
   }
 
 }
diff --git a/src/Validator/CronUpdateVersionValidator.php b/src/Validator/CronUpdateVersionValidator.php
deleted file mode 100644
index 0cf8c3b183..0000000000
--- a/src/Validator/CronUpdateVersionValidator.php
+++ /dev/null
@@ -1,124 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Validator;
-
-use Composer\Semver\Semver;
-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 {
-    // @todo Add test coverage for the call to getMode() in
-    //   https://www.drupal.org/i/3276662.
-    return $stage instanceof CronUpdater && $stage->getMode() !== CronUpdater::DISABLED;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getNextPossibleUpdateVersion(): ?string {
-    $project_info = new ProjectInfo('drupal');
-    $installed_version = $project_info->getInstalledVersion();
-    if ($possible_releases = $project_info->getInstallableReleases()) {
-      // The next possible update version for cron should be the lowest possible
-      // release.
-      $possible_release = array_pop($possible_releases);
-      if (Semver::satisfies($possible_release->getVersion(), "~$installed_version")) {
-        return $possible_release->getVersion();
-      }
-    }
-    return NULL;
-  }
-
-  /**
-   * {@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),
-      ]);
-    }
-
-    // We cannot use dependency injection to get the cron updater because that
-    // would create a circular service dependency.
-    $level = \Drupal::service('automatic_updates.cron_updater')
-      ->getMode();
-
-    // 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.
-    if ($level === CronUpdater::SECURITY) {
-      $releases = (new ProjectInfo('drupal'))->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/InstalledVersionValidator.php b/src/Validator/InstalledVersionValidator.php
deleted file mode 100644
index 0a827de58b..0000000000
--- a/src/Validator/InstalledVersionValidator.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Validator;
-
-use Drupal\automatic_updates\Event\ReadinessCheckEvent;
-use Drupal\automatic_updates\ProjectInfo;
-use Drupal\automatic_updates\Updater;
-use Drupal\Core\Extension\ExtensionVersion;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-/**
- * Validates that the site can update from the installed version of Drupal.
- *
- * @internal
- *   This class is an internal part of the module's update handling and
- *   should not be used by external code.
- */
-class InstalledVersionValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * Checks that the site can update from the installed version of Drupal.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
-   */
-  public function checkInstalledVersion(PreOperationStageEvent $event): void {
-    // This check only works with Automatic Updates.
-    if (!$event->getStage() instanceof Updater) {
-      return;
-    }
-
-    $installed_version = (new ProjectInfo('drupal'))->getInstalledVersion();
-    $extra = ExtensionVersion::createFromVersionString($installed_version)
-      ->getVersionExtra();
-
-    if ($extra === 'dev') {
-      $message = $this->t('Drupal cannot be automatically updated from the installed version, @installed_version, because automatic updates from a dev version to any other version are not supported.', [
-        '@installed_version' => $installed_version,
-      ]);
-      $event->addError([$message]);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      ReadinessCheckEvent::class => 'checkInstalledVersion',
-      PreCreateEvent::class => 'checkInstalledVersion',
-    ];
-  }
-
-}
diff --git a/src/Validator/UpdateReleaseValidator.php b/src/Validator/UpdateReleaseValidator.php
deleted file mode 100644
index 97449b36d3..0000000000
--- a/src/Validator/UpdateReleaseValidator.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Validator;
-
-use Drupal\automatic_updates\ProjectInfo;
-use Drupal\automatic_updates\Updater;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-/**
- * Validates that the target release of Drupal core is secure and supported.
- *
- * @internal
- *   This class is an internal part of the module's update handling and
- *   should not be used by external code.
- */
-class UpdateReleaseValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * Checks that the target version of Drupal core is 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.
-    if (!$stage instanceof Updater) {
-      return;
-    }
-
-    $package_versions = $stage->getPackageVersions();
-    // The updater will only update Drupal core, so all production dependencies
-    // will be Drupal core packages.
-    $target_version = reset($package_versions['production']);
-
-    // If the target version isn't in the list of installable releases, then it
-    // isn't secure and supported and we should flag an error.
-    $releases = (new ProjectInfo('drupal'))->getInstallableReleases();
-    if (empty($releases) || !array_key_exists($target_version, $releases)) {
-      $message = $this->t('Cannot update Drupal core to @target_version because it is not in the list of installable releases.', [
-        '@target_version' => $target_version,
-      ]);
-      $event->addError([$message]);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'checkRelease',
-    ];
-  }
-
-}
diff --git a/src/Validator/UpdateVersionValidator.php b/src/Validator/UpdateVersionValidator.php
deleted file mode 100644
index bd87af4ab8..0000000000
--- a/src/Validator/UpdateVersionValidator.php
+++ /dev/null
@@ -1,215 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Validator;
-
-use Composer\Semver\Comparator;
-use Composer\Semver\Semver;
-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\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 {
-
-  use StringTranslationTrait;
-
-  /**
-   * The config factory service.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * Constructs a UpdateVersionValidation object.
-   *
-   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
-   *   The translation service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory service.
-   */
-  public function __construct(TranslationInterface $translation, ConfigFactoryInterface $config_factory) {
-    $this->setStringTranslation($translation);
-    $this->configFactory = $config_factory;
-  }
-
-  /**
-   * Returns the running core version, according to the Update module.
-   *
-   * @return string
-   *   The running core version as known to the Update module.
-   */
-  protected function getCoreVersion(): string {
-    return (new ProjectInfo('drupal'))->getInstalledVersion();
-  }
-
-  /**
-   * Validates that core is being updated within an allowed version range.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
-   */
-  public function checkUpdateVersion(PreOperationStageEvent $event): void {
-    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();
-      if (!$package_versions) {
-        // During readiness checks we might not have a version to update to.
-        // Use the next possible update version to run checks against.
-        return $this->getNextPossibleUpdateVersion();
-      }
-    }
-    else {
-      // If the stage has begun its life cycle, we expect it knows the desired
-      // package versions.
-      $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];
-    }
-    return NULL;
-  }
-
-  /**
-   * Gets the next possible update version, if any.
-   *
-   * @return string|null
-   *   The next possible update version if available, otherwise NULL.
-   */
-  protected function getNextPossibleUpdateVersion(): ?string {
-    $project_info = new ProjectInfo('drupal');
-    $installed_version = $project_info->getInstalledVersion();
-    if ($possible_releases = $project_info->getInstallableReleases()) {
-      foreach ($possible_releases as $possible_release) {
-        $possible_version = $possible_release->getVersion();
-        if (Semver::satisfies($possible_release->getVersion(), "~$installed_version")) {
-          return $possible_version;
-        }
-      }
-    }
-    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();
-    $variables = [
-      '@to_version' => $to_version_string,
-      '@from_version' => $from_version_string,
-    ];
-    $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 (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),
-      ]);
-    }
-    $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),
-      ]);
-    }
-    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
-      if (!$this->configFactory->get('automatic_updates.settings')->get('allow_core_minor_updates')) {
-        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),
-        ]);
-      }
-    }
-    return NULL;
-  }
-
-  /**
-   * Determines if a stage is supported by this validator.
-   *
-   * @param \Drupal\package_manager\Stage $stage
-   *   The stage to check.
-   *
-   * @return bool
-   *   TRUE if the stage is supported by this validator, otherwise FALSE.
-   */
-  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/Validator/VersionPolicy/ForbidDevSnapshot.php b/src/Validator/VersionPolicy/ForbidDevSnapshot.php
new file mode 100644
index 0000000000..ca6842a650
--- /dev/null
+++ b/src/Validator/VersionPolicy/ForbidDevSnapshot.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that forbids updating from a dev snapshot.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class ForbidDevSnapshot {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the installed version of Drupal is a dev snapshot.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version): array {
+    $extra = ExtensionVersion::createFromVersionString($installed_version)
+      ->getVersionExtra();
+
+    if ($extra === 'dev') {
+      return [
+        $this->t('Drupal cannot be automatically updated from the installed version, @installed_version, because automatic updates from a dev version to any other version are not supported.', [
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/ForbidDowngrade.php b/src/Validator/VersionPolicy/ForbidDowngrade.php
new file mode 100644
index 0000000000..93f05e0fc9
--- /dev/null
+++ b/src/Validator/VersionPolicy/ForbidDowngrade.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Composer\Semver\Comparator;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that forbids downgrading.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class ForbidDowngrade {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the target version of Drupal is older than the installed version.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    if (Comparator::lessThan($target_version, $installed_version)) {
+      return [
+        $this->t('Update version @target_version is lower than @installed_version, downgrading is not supported.', [
+          '@target_version' => $target_version,
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/ForbidMinorUpdates.php b/src/Validator/VersionPolicy/ForbidMinorUpdates.php
new file mode 100644
index 0000000000..86327d4161
--- /dev/null
+++ b/src/Validator/VersionPolicy/ForbidMinorUpdates.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\automatic_updates\VersionParsingTrait;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule forbidding minor updates during cron.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class ForbidMinorUpdates {
+
+  use StringTranslationTrait;
+  use VersionParsingTrait;
+
+  /**
+   * Checks if the target minor version of Drupal is different than installed.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_minor = static::getMajorAndMinorVersion($installed_version);
+    $target_minor = static::getMajorAndMinorVersion($target_version);
+
+    if ($installed_minor !== $target_minor) {
+      return [
+        $this->t('Drupal cannot be automatically updated from its current version, @installed_version, to the recommended version, @target_version, because automatic updates from one minor version to another are not supported during cron.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/MajorVersionMatch.php b/src/Validator/VersionPolicy/MajorVersionMatch.php
new file mode 100644
index 0000000000..58709075b7
--- /dev/null
+++ b/src/Validator/VersionPolicy/MajorVersionMatch.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that requires updating within the same major version.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class MajorVersionMatch {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is in the same minor as installed.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_major = ExtensionVersion::createFromVersionString($installed_version)
+      ->getMajorVersion();
+    $target_major = ExtensionVersion::createFromVersionString($target_version)
+      ->getMajorVersion();
+
+    if ($installed_major !== $target_major) {
+      return [
+        $this->t('Drupal cannot be automatically updated from its current version, @installed_version, to the recommended version, @target_version, because automatic updates from one major version to another are not supported.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/MinorUpdatesEnabled.php b/src/Validator/VersionPolicy/MinorUpdatesEnabled.php
new file mode 100644
index 0000000000..52f6fd80e1
--- /dev/null
+++ b/src/Validator/VersionPolicy/MinorUpdatesEnabled.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\automatic_updates\VersionParsingTrait;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * A policy rule that allows minor updates if enabled in configuration.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class MinorUpdatesEnabled implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+  use VersionParsingTrait;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  private $configFactory;
+
+  /**
+   * Constructs a MinorUpdatesEnabled object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory) {
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory')
+    );
+  }
+
+  /**
+   * Checks that the target minor version of Drupal can be updated to.
+   *
+   * The update will only be allowed if the allow_core_minor_updates flag is
+   * set to TRUE in config.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_minor = static::getMajorAndMinorVersion($installed_version);
+    $target_minor = static::getMajorAndMinorVersion($target_version);
+
+    if ($installed_minor === $target_minor) {
+      return [];
+    }
+
+    $minor_updates_allowed = $this->configFactory->get('automatic_updates.settings')
+      ->get('allow_core_minor_updates');
+
+    if (!$minor_updates_allowed) {
+      return [
+        $this->t('Drupal cannot be automatically updated from its current version, @installed_version, to the recommended version, @target_version, because automatic updates from one minor version to another are not supported.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/StableReleaseInstalled.php b/src/Validator/VersionPolicy/StableReleaseInstalled.php
new file mode 100644
index 0000000000..299126d296
--- /dev/null
+++ b/src/Validator/VersionPolicy/StableReleaseInstalled.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that requiring the installed version to be stable.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class StableReleaseInstalled {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the installed version of Drupal is a stable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version): array {
+    $extra = ExtensionVersion::createFromVersionString($installed_version)
+      ->getVersionExtra();
+
+    if ($extra) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @installed_version, because Automatic Updates only supports updating from stable versions during cron.', [
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/TargetSecurityRelease.php b/src/Validator/VersionPolicy/TargetSecurityRelease.php
new file mode 100644
index 0000000000..4ac64316a1
--- /dev/null
+++ b/src/Validator/VersionPolicy/TargetSecurityRelease.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be a security release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class TargetSecurityRelease {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a security release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version, array $available_releases): array {
+    if (!$available_releases[$target_version]->isSecurityRelease()) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @installed_version, to the recommended version, @target_version, because @target_version is not a security release.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/TargetVersionInstallable.php b/src/Validator/VersionPolicy/TargetVersionInstallable.php
new file mode 100644
index 0000000000..de38041869
--- /dev/null
+++ b/src/Validator/VersionPolicy/TargetVersionInstallable.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be an installable release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class TargetVersionInstallable {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a known installable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version, array $available_releases): array {
+    // If the target version isn't in the list of installable releases, we
+    // should flag an error.
+    if (empty($available_releases) || !array_key_exists($target_version, $available_releases)) {
+      return [
+        $this->t('Cannot update Drupal core to @target_version because it is not in the list of installable releases.', [
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicy/TargetVersionStable.php b/src/Validator/VersionPolicy/TargetVersionStable.php
new file mode 100644
index 0000000000..787bd477d6
--- /dev/null
+++ b/src/Validator/VersionPolicy/TargetVersionStable.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be a stable release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+class TargetVersionStable {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a stable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $extra = ExtensionVersion::createFromVersionString($target_version)
+      ->getVersionExtra();
+
+    if ($extra) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron to the recommended version, @target_version, because Automatic Updates only supports updating to stable versions during cron.', [
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/src/Validator/VersionPolicyValidator.php b/src/Validator/VersionPolicyValidator.php
new file mode 100644
index 0000000000..9e5d662bdf
--- /dev/null
+++ b/src/Validator/VersionPolicyValidator.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator;
+
+use Composer\Semver\Semver;
+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\Validator\VersionPolicy\ForbidDowngrade;
+use Drupal\automatic_updates\Validator\VersionPolicy\ForbidMinorUpdates;
+use Drupal\automatic_updates\Validator\VersionPolicy\MajorVersionMatch;
+use Drupal\automatic_updates\Validator\VersionPolicy\MinorUpdatesEnabled;
+use Drupal\automatic_updates\Validator\VersionPolicy\StableReleaseInstalled;
+use Drupal\automatic_updates\Validator\VersionPolicy\ForbidDevSnapshot;
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetSecurityRelease;
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionInstallable;
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionStable;
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates the installed and target versions of Drupal before an update.
+ *
+ * @internal
+ *   This class is an internal part of the module's update handling and should
+ *   not be used by external code.
+ */
+final class VersionPolicyValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The class resolver service.
+   *
+   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+   */
+  private $classResolver;
+
+  /**
+   * Constructs a VersionPolicyValidator object.
+   *
+   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
+   *   The class resolver service.
+   */
+  public function __construct(ClassResolverInterface $class_resolver) {
+    $this->classResolver = $class_resolver;
+  }
+
+  /**
+   * Validates a target version of Drupal core.
+   *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater which will perform the update.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if it is not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages returned from the first policy rule which rejected
+   *   the given target version.
+   *
+   * @see \Drupal\automatic_updates\Validator\VersionPolicy\RuleBase::validate()
+   */
+  public function validateVersion(Updater $updater, ?string $target_version): array {
+    // Check that the installed version of Drupal isn't a dev snapshot.
+    $rules = [
+      ForbidDevSnapshot::class,
+    ];
+
+    // If the target version is known, it must conform to a few basic rules.
+    if ($target_version) {
+      // The target version must be newer than the installed version...
+      $rules[] = ForbidDowngrade::class;
+      // ...and in the same major version as the installed version...
+      $rules[] = MajorVersionMatch::class;
+      // ...and it must be a known, secure, installable release.
+      $rules[] = TargetVersionInstallable::class;
+    }
+
+    // If this is a cron update, we may need to do additional checks.
+    if ($updater instanceof CronUpdater) {
+      $mode = $updater->getMode();
+
+      if ($mode !== CronUpdater::DISABLED) {
+        // If cron updates are enabled, the installed version must be stable;
+        // no alphas, betas, or RCs.
+        $rules[] = StableReleaseInstalled::class;
+
+        // If the target version is known, more rules apply.
+        if ($target_version) {
+          // The target version must be stable too...
+          $rules[] = TargetVersionStable::class;
+          // ...and it must be in the same minor as the installed version.
+          $rules[] = ForbidMinorUpdates::class;
+
+          // If only security updates are allowed during cron, the target
+          // version must be a security release.
+          if ($mode === CronUpdater::SECURITY) {
+            $rules[] = TargetSecurityRelease::class;
+          }
+        }
+      }
+    }
+    // If this is not a cron update, and we know the target version, minor
+    // version updates are allowed if configuration says so.
+    elseif ($target_version) {
+      $rules[] = MinorUpdatesEnabled::class;
+    }
+
+    $installed_version = $this->getInstalledVersion();
+    $available_releases = static::getAvailableReleases($updater);
+
+    // Invoke each rule in the order that they were added to $rules, stopping
+    // when one returns error messages.
+    // @todo Return all the error messages in https://www.drupal.org/i/3281379.
+    foreach ($rules as $rule) {
+      $messages = $this->classResolver
+        ->getInstanceFromDefinition($rule)
+        ->validate($installed_version, $target_version, $available_releases);
+
+      if ($messages) {
+        return $messages;
+      }
+    }
+    return [];
+  }
+
+  /**
+   * Checks that the target version of Drupal is valid.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function checkVersion(StageEvent $event): void {
+    $stage = $event->getStage();
+
+    // Only do these checks for automatic updates.
+    if (!$stage instanceof Updater) {
+      return;
+    }
+    $target_version = $this->getTargetVersion($event);
+
+    $messages = $this->validateVersion($stage, $target_version);
+    if ($messages) {
+      $summary = $this->t('Updating from Drupal @installed_version to @target_version is not allowed.', [
+        '@installed_version' => $this->getInstalledVersion(),
+        '@target_version' => $target_version,
+      ]);
+      $event->addError($messages, $summary);
+    }
+  }
+
+  /**
+   * Returns the target version of Drupal core.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   *
+   * @return string|null
+   *   The target version of Drupal core, or NULL if it could not be determined.
+   *
+   * @todo Throw an exception in certain (maybe all) situations if we cannot
+   *   figure out the target version in https://www.drupal.org/i/3280180.
+   */
+  private function getTargetVersion(StageEvent $event): ?string {
+    $updater = $event->getStage();
+
+    if ($event instanceof ReadinessCheckEvent) {
+      $package_versions = $event->getPackageVersions();
+    }
+    else {
+      $package_versions = $updater->getPackageVersions()['production'];
+    }
+
+    if ($package_versions) {
+      $core_package_name = key($updater->getActiveComposer()->getCorePackages());
+      return $package_versions[$core_package_name];
+    }
+    elseif ($event instanceof ReadinessCheckEvent) {
+      return $this->getTargetVersionFromAvailableReleases($updater);
+    }
+    else {
+      return NULL;
+    }
+  }
+
+  /**
+   * Returns the target version of Drupal from the list of available releases.
+   *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater which will perform the update.
+   *
+   * @return string|null
+   *   The target version of Drupal core, or NULL if it could not be determined.
+   *
+   * @todo Expand this doc comment to explain how the list of available releases
+   *   is fetched, sorted, and filtered through (i.e., must match the current
+   *   minor). Maybe reference ProjectInfo::getInstallableReleases().
+   */
+  private function getTargetVersionFromAvailableReleases(Updater $updater): ?string {
+    $installed_version = $this->getInstalledVersion();
+
+    foreach (self::getAvailableReleases($updater) as $possible_release) {
+      $possible_version = $possible_release->getVersion();
+      if (Semver::satisfies($possible_version, "~$installed_version")) {
+        return $possible_version;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Returns the available releases of Drupal core for a given updater.
+   *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater which will perform the update.
+   *
+   * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[]
+   *   The available releases of Drupal core, keyed by version number and in
+   *   descending order (i.e., newest first). Will be in ascending order (i.e.,
+   *   oldest first) if $updater is the cron updater.
+   *
+   * @see \Drupal\automatic_updates\ProjectInfo::getInstallableReleases()
+   */
+  private static function getAvailableReleases(Updater $updater): array {
+    $project_info = new ProjectInfo('drupal');
+    $available_releases = $project_info->getInstallableReleases() ?? [];
+
+    if ($updater instanceof CronUpdater) {
+      $available_releases = array_reverse($available_releases);
+    }
+    return $available_releases;
+  }
+
+  /**
+   * Returns the currently installed version of Drupal core.
+   *
+   * @return string|null
+   *   The currently installed version of Drupal core, or NULL if it could not
+   *   be determined.
+   */
+  private function getInstalledVersion(): ?string {
+    return (new ProjectInfo('drupal'))->getInstalledVersion();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'checkVersion',
+      PreCreateEvent::class => 'checkVersion',
+    ];
+  }
+
+}
diff --git a/src/VersionParsingTrait.php b/src/VersionParsingTrait.php
index c8d52f8e25..2e2a3ae5e9 100644
--- a/src/VersionParsingTrait.php
+++ b/src/VersionParsingTrait.php
@@ -34,4 +34,19 @@ trait VersionParsingTrait {
     return count($version_parts) === 3 ? $version_parts[2] : NULL;
   }
 
+  /**
+   * Returns the semantic major.minor numbers of a version string.
+   *
+   * @param string $version
+   *   The version string.
+   *
+   * @return string
+   *   The major.minor numbers of the version string. For example, if $version
+   *   is 8.9.1, '8.9' will be returned.
+   */
+  protected static function getMajorAndMinorVersion(string $version): string {
+    $version = ExtensionVersion::createFromVersionString($version);
+    return $version->getMajorVersion() . '.' . $version->getMinorVersion();
+  }
+
 }
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
index 0d29b6e115..1bc31480c7 100644
--- 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
@@ -35,6 +35,19 @@
     <term><name>Release type</name><value>Security update</value></term>
   </terms>
  </release>
+    <release>
+        <name>Drupal 9.8.1-beta1</name>
+        <version>9.8.1-beta1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-8-1-beta1-release</release_link>
+        <download_link>http://example.com/drupal-9-8-1-beta1.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>Security release</value></term>
+        </terms>
+    </release>
  <release>
    <name>Drupal 9.8.0</name>
    <version>9.8.0</version>
diff --git a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
index de4baeb504..3788fb4bb4 100644
--- a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
+++ b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
@@ -65,8 +65,8 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
     // have run.
     $this->registerPostUpdateFunctions();
 
-    // By default, pretend we're running Drupal core 9.8.0 and a non-security
-    // update to 9.8.1 is available.
+    // By default, pretend we're running Drupal core 9.8.1 and a non-security
+    // update to 9.8.2 is available.
     $this->setCoreVersion('9.8.1');
     $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml']);
 
diff --git a/tests/src/Kernel/ProjectInfoTest.php b/tests/src/Kernel/ProjectInfoTest.php
index 7b11e703b0..e2b6c3ed41 100644
--- a/tests/src/Kernel/ProjectInfoTest.php
+++ b/tests/src/Kernel/ProjectInfoTest.php
@@ -92,7 +92,7 @@ class ProjectInfoTest extends AutomaticUpdatesKernelTestBase {
       'core, skip insecure releases and return secure releases' => [
         'drupal.9.8.2-older-sec-release.xml',
         '9.7.0-alpha1',
-        ['9.8.2', '9.8.1', '9.8.0-alpha1', '9.7.1'],
+        ['9.8.2', '9.8.1', '9.8.1-beta1', '9.8.0-alpha1', '9.7.1'],
       ],
       'contrib, semver and legacy' => [
         'aaa_automatic_updates_test.9.8.2.xml',
diff --git a/tests/src/Kernel/ReadinessValidation/CronUpdateVersionValidatorTest.php b/tests/src/Kernel/ReadinessValidation/CronUpdateVersionValidatorTest.php
deleted file mode 100644
index dbb83d43c0..0000000000
--- a/tests/src/Kernel/ReadinessValidation/CronUpdateVersionValidatorTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
-
-use Drupal\automatic_updates\CronUpdater;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\CronUpdateVersionValidator
- *
- * @group automatic_updates
- */
-class CronUpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  use PackageManagerBypassTestTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates'];
-
-  /**
-   * Data provider for ::testValidationSkippedIfCronUpdatesDisabled().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerValidationSkippedIfCronUpdatesDisabled(): array {
-    $unstable_current_version = [
-      ValidationResult::createError([
-        'Drupal cannot be automatically updated during cron from its current version, 9.7.0-alpha1, because Automatic Updates only supports updating from stable versions during cron.',
-      ]),
-    ];
-    return [
-      'disabled' => [
-        CronUpdater::DISABLED,
-        [],
-      ],
-      'security only' => [
-        CronUpdater::SECURITY,
-        $unstable_current_version,
-      ],
-      'all' => [
-        CronUpdater::ALL,
-        $unstable_current_version,
-      ],
-    ];
-  }
-
-  /**
-   * Tests that validation is skipped if cron updates are disabled.
-   *
-   * @param string $cron_setting
-   *   The value of the automatic_updates.settings:cron config setting.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerValidationSkippedIfCronUpdatesDisabled
-   */
-  public function testValidationSkippedIfCronUpdatesDisabled(string $cron_setting, array $expected_results): void {
-    // Set the currently installed version of core to a version that cannot be
-    // automatically updated, and will always trigger a validation error. This
-    // way, we can be certain that validation only happens if cron updates are
-    // enabled.
-    $this->setCoreVersion('9.7.0-alpha1');
-    $this->config('automatic_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessValidation/InstalledVersionValidatorTest.php b/tests/src/Kernel/ReadinessValidation/InstalledVersionValidatorTest.php
deleted file mode 100644
index 69ace78cff..0000000000
--- a/tests/src/Kernel/ReadinessValidation/InstalledVersionValidatorTest.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
-
-use Drupal\automatic_updates\CronUpdater;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\InstalledVersionValidator
- *
- * @group automatic_updates
- */
-class InstalledVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates'];
-
-  /**
-   * Tests that the installed version of Drupal is checked for updateability.
-   */
-  public function testInstalledVersionValidation(): void {
-    $this->setCoreVersion('9.8.0-dev');
-    // Disable cron to avoid messages from other validators.
-    // @see \Drupal\automatic_updates\Validator\CronUpdateVersionValidator
-    $this->config('automatic_updates.settings')
-      ->set('cron', CronUpdater::DISABLED)
-      ->save();
-
-    $result = ValidationResult::createError([
-      'Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.',
-    ]);
-    $this->assertCheckerResultsFromManager([$result], TRUE);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessValidation/UpdateReleaseValidatorTest.php b/tests/src/Kernel/ReadinessValidation/UpdateReleaseValidatorTest.php
deleted file mode 100644
index 4234d103dc..0000000000
--- a/tests/src/Kernel/ReadinessValidation/UpdateReleaseValidatorTest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
-
-use Drupal\automatic_updates\Exception\UpdateException;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\UpdateReleaseValidator
- *
- * @group automatic_updates
- */
-class UpdateReleaseValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates'];
-
-  /**
-   * Tests that an error is raised when trying to update to an unknown release.
-   */
-  public function testUnknownReleaseRaisesError(): void {
-    $result = ValidationResult::createError([
-      'Cannot update Drupal core to 9.8.99 because it is not in the list of installable releases.',
-    ]);
-
-    try {
-      $this->container->get('automatic_updates.updater')->begin([
-        'drupal' => '9.8.99',
-      ]);
-      $this->fail('Expected an exception to be thrown, but it was not.');
-    }
-    catch (UpdateException $e) {
-      $this->assertValidationResultsEqual([$result], $e->getResults());
-    }
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
deleted file mode 100644
index 3193ada45a..0000000000
--- a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
+++ /dev/null
@@ -1,332 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
-
-use Drupal\automatic_updates\CronUpdater;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
-use Psr\Log\Test\TestLogger;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\UpdateVersionValidator
- *
- * @group automatic_updates
- */
-class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  use PackageManagerBypassTestTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates'];
-
-  /**
-   * The logger for cron updates.
-   *
-   * @var \Psr\Log\Test\TestLogger
-   */
-  private $logger;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('automatic_updates')
-      ->addLogger($this->logger);
-  }
-
-  /**
-   * Data provider for all possible cron update frequencies.
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerOnCurrentVersion(): array {
-    return [
-      'disabled' => [CronUpdater::DISABLED],
-      'security' => [CronUpdater::SECURITY],
-      'all' => [CronUpdater::ALL],
-    ];
-  }
-
-  /**
-   * Tests an update version that is same major & minor version as the current.
-   *
-   * @param string $cron_setting
-   *   The value of the automatic_updates.settings:cron config setting.
-   *
-   * @dataProvider providerOnCurrentVersion
-   */
-  public function testOnCurrentVersion(string $cron_setting): void {
-    $this->setCoreVersion('9.8.2');
-    $this->config('automatic_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager([], TRUE);
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-  /**
-   * Data provider for ::testMinorUpdates().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerMinorUpdates(): array {
-    return [
-      'cron disabled, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::DISABLED,
-      ],
-      'cron disabled, minor updates allowed' => [
-        TRUE,
-        CronUpdater::DISABLED,
-      ],
-      'security updates during cron, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::SECURITY,
-      ],
-      'security updates during cron, minor updates allowed' => [
-        TRUE,
-        CronUpdater::SECURITY,
-      ],
-      'cron enabled, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::ALL,
-      ],
-      'cron enabled, minor updates allowed' => [
-        TRUE,
-        CronUpdater::ALL,
-      ],
-    ];
-  }
-
-  /**
-   * Tests an update version that is a different minor version than the current.
-   *
-   * @param bool $allow_minor_updates
-   *   Whether or not updates across minor core versions are allowed in config.
-   * @param string $cron_setting
-   *   Whether cron updates are enabled, and how often; should be one of the
-   *   constants in \Drupal\automatic_updates\CronUpdater. This determines which
-   *   stage the validator will use; if cron updates are enabled at all,
-   *   it will be an instance of CronUpdater.
-   *
-   * @dataProvider providerMinorUpdates
-   */
-  public function testMinorUpdates(bool $allow_minor_updates, string $cron_setting): void {
-    $this->config('automatic_updates.settings')
-      ->set('allow_core_minor_updates', $allow_minor_updates)
-      ->set('cron', $cron_setting)
-      ->save();
-
-    // In order to test what happens when only security updates are enabled
-    // during cron (the default behavior), ensure that the latest available
-    // release is a security update.
-    $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../../fixtures/release-history/drupal.9.8.1-security.xml']);
-
-    $this->setCoreVersion('9.7.1');
-    $this->assertCheckerResultsFromManager([], TRUE);
-
-    $this->container->get('cron')->run();
-
-    $this->assertUpdateStagedTimes(0);
-    $this->assertEmpty($this->logger->records);
-  }
-
-  /**
-   * Data provider for ::testCronUpdateTwoPatchReleasesAhead().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerCronUpdateTwoPatchReleasesAhead(): array {
-    return [
-      'disabled' => [
-        CronUpdater::DISABLED,
-        [],
-        0,
-      ],
-      'security only' => [
-        CronUpdater::SECURITY,
-        [
-          ValidationResult::createError([
-            'Drupal cannot be automatically updated during cron from its current version, 9.8.0, to the recommended version, 9.8.1, because 9.8.1 is not a security release.',
-          ]),
-        ],
-        0,
-      ],
-      'all' => [
-        CronUpdater::ALL,
-        [],
-        1,
-      ],
-    ];
-  }
-
-  /**
-   * Tests a cron update two patch releases ahead of the current version.
-   *
-   * @param string $cron_setting
-   *   The value of the automatic_updates.settings:cron config setting.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results, which should be logged as errors if the
-   *   update is attempted during cron.
-   * @param int $expected_stage_times
-   *   The expected number of times the update should have been staged.
-   *
-   * @dataProvider providerCronUpdateTwoPatchReleasesAhead
-   */
-  public function testCronUpdateTwoPatchReleasesAhead(string $cron_setting, array $expected_results, int $expected_stage_times): void {
-    $this->setCoreVersion('9.8.0');
-    $this->config('automatic_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes($expected_stage_times);
-  }
-
-  /**
-   * Data provider for ::testCronUpdateOnePatchReleaseAhead().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerCronUpdateOnePatchReleaseAhead(): array {
-    return [
-      'disabled' => [
-        CronUpdater::DISABLED,
-        FALSE,
-      ],
-      // The latest release is not a security update, so the update will only
-      // happen if cron is updates are allowed for any patch release.
-      'security' => [
-        CronUpdater::SECURITY,
-        FALSE,
-      ],
-      'all' => [
-        CronUpdater::ALL,
-        TRUE,
-      ],
-    ];
-  }
-
-  /**
-   * Tests a cron update one patch release ahead of the current version.
-   *
-   * @param string $cron_setting
-   *   The value of the automatic_updates.settings:cron config setting.
-   * @param bool $will_update
-   *   TRUE if the update will occur, otherwise FALSE.
-   *
-   * @dataProvider providerCronUpdateOnePatchReleaseAhead
-   */
-  public function testCronUpdateOnePatchReleaseAhead(string $cron_setting, bool $will_update): void {
-    $this->config('automatic_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-    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);
-  }
-
-  /**
-   * Data provider for ::testInvalidCronUpdate().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerInvalidCronUpdate(): array {
-    $unstable_current_version = ValidationResult::createError([
-      'Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha1, because Automatic Updates only supports updating from stable versions during cron.',
-    ]);
-
-    return [
-      'unstable current version, cron disabled' => [
-        CronUpdater::DISABLED,
-        '9.8.0-alpha1',
-        // If cron updates are disabled, no error should be flagged, because
-        // 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],
-      ],
-      'unstable current version, all updates allowed' => [
-        CronUpdater::ALL,
-        '9.8.0-alpha1',
-        [$unstable_current_version],
-      ],
-      // @todo In the 3 following test cases the installed version is not
-      //   in a supported branch. These test expectations should be changed or
-      //   moved to a new test when we add a validator to check if the installed
-      //   version is in a supported branch in https://www.drupal.org/i/3275883.
-      'different current major, cron disabled' => [
-        CronUpdater::DISABLED,
-        '8.9.1',
-        [],
-      ],
-      'different current major, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '8.9.1',
-        [],
-      ],
-      'different current major, all updates allowed' => [
-        CronUpdater::ALL,
-        '8.9.1',
-        [],
-      ],
-    ];
-  }
-
-  /**
-   * Tests invalid version jumps before and during a cron update.
-   *
-   * @param string $cron_setting
-   *   The value of the automatic_updates.settings:cron config setting.
-   * @param string $current_core_version
-   *   The current core version from which we are updating.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The validation results, if any, that should be flagged during readiness
-   *   checks.
-   *
-   * @dataProvider providerInvalidCronUpdate
-   */
-  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)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-
-    // Try running the update during cron, regardless of the validation results,
-    // and ensure it doesn't happen. In certain situations, this will be because
-    // of $cron_setting (e.g., if the latest release is a regular patch release
-    // but only security updates are allowed during cron); in other situations,
-    // it will be due to validation errors being raised when the staging area is
-    // created (in which case, we expect the errors to be logged).
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php b/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
new file mode 100644
index 0000000000..d69cd59fcc
--- /dev/null
+++ b/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
@@ -0,0 +1,377 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
+
+use Drupal\automatic_updates\CronUpdater;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicyValidator
+ *
+ * @group automatic_updates
+ */
+class VersionPolicyValidatorTest extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates'];
+
+  /**
+   * Data provider for ::testReadinessCheck().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerReadinessCheck(): array {
+    $metadata_dir = __DIR__ . '/../../../fixtures/release-history';
+
+    return [
+      // Updating from a dev, alpha, beta, or RC release is not allowed during
+      // cron. The first case is a control to prove that a legitimate
+      // patch-level update from a stable release never raises a readiness
+      // error.
+      'stable release installed' => [
+        '9.8.0',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL],
+        [],
+      ],
+      // This case proves that updating from a dev snapshot is never allowed,
+      // regardless of configuration.
+      'dev snapshot installed' => [
+        '9.8.0-dev',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createValidationResult('9.8.0-dev', '9.8.1', [
+            'Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.',
+          ]),
+        ],
+      ],
+      // The next six cases prove that updating from an alpha, beta, or RC
+      // release raises a readiness error if unattended updates are enabled.
+      'alpha installed, cron disabled' => [
+        '9.8.0-alpha1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'alpha installed, cron enabled' => [
+        '9.8.0-alpha1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createValidationResult('9.8.0-alpha1', '9.8.1', [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha1, because Automatic Updates only supports updating from stable versions during cron.',
+          ]),
+        ],
+      ],
+      'beta installed, cron disabled' => [
+        '9.8.0-beta2',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'beta installed, cron enabled' => [
+        '9.8.0-beta2',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createValidationResult('9.8.0-beta2', '9.8.1', [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta2, because Automatic Updates only supports updating from stable versions during cron.',
+          ]),
+        ],
+      ],
+      'rc installed, cron disabled' => [
+        '9.8.0-rc3',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'rc installed, cron enabled' => [
+        '9.8.0-rc3',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createValidationResult('9.8.0-rc3', '9.8.1', [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc3, because Automatic Updates only supports updating from stable versions during cron.',
+          ]),
+        ],
+      ],
+      // These two cases prove that, if only security updates are allowed
+      // during cron, a readiness error is raised if the next available release
+      // is not a security release.
+      'update to normal release allowed' => [
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::DISABLED, CronUpdater::ALL],
+        [],
+      ],
+      'update to normal release, security only in cron' => [
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY],
+        [
+          $this->createValidationResult('9.8.1', '9.8.2', [
+            '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.',
+          ]),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests target version validation during readiness checks.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string $release_metadata
+   *   The path of the core release metadata to serve to the update system.
+   * @param string[] $cron_modes
+   *   The modes for unattended updates. Can contain any of
+   *   \Drupal\automatic_updates\CronUpdater::DISABLED,
+   *   \Drupal\automatic_updates\CronUpdater::SECURITY, and
+   *   \Drupal\automatic_updates\CronUpdater::ALL.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerReadinessCheck
+   */
+  public function testReadinessCheck(string $installed_version, string $release_metadata, array $cron_modes, array $expected_results): void {
+    $this->setCoreVersion($installed_version);
+    $this->setReleaseMetadata(['drupal' => $release_metadata]);
+
+    foreach ($cron_modes as $cron_mode) {
+      $this->config('automatic_updates.settings')
+        ->set('cron', $cron_mode)
+        ->save();
+
+      $this->assertCheckerResultsFromManager($expected_results, TRUE);
+    }
+  }
+
+  /**
+   * Data provider for ::testApi().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerApi(): array {
+    $metadata_dir = __DIR__ . '/../../../fixtures/release-history';
+
+    return [
+      'valid target, dev snapshot installed' => [
+        ['automatic_updates.updater', 'automatic_updates.cron_updater'],
+        '9.8.0-dev',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.1'],
+        [
+          $this->createValidationResult('9.8.0-dev', '9.8.1', [
+            'Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.',
+          ]),
+        ],
+      ],
+      // The following cases can only happen by explicitly supplying the updater
+      // with an invalid target version.
+      'downgrade' => [
+        ['automatic_updates.updater', 'automatic_updates.cron_updater'],
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.0'],
+        [
+          $this->createValidationResult('9.8.1', '9.8.0', [
+            'Update version 9.8.0 is lower than 9.8.1, downgrading is not supported.',
+          ]),
+        ],
+      ],
+      'major version upgrade' => [
+        ['automatic_updates.updater', 'automatic_updates.cron_updater'],
+        '8.9.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [
+          $this->createValidationResult('8.9.1', '9.8.2', [
+            '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.',
+          ]),
+        ],
+      ],
+      'unsupported target version' => [
+        ['automatic_updates.updater', 'automatic_updates.cron_updater'],
+        '9.8.0',
+        "$metadata_dir/drupal.9.8.2-unsupported_unpublished.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.1'],
+        [
+          $this->createValidationResult('9.8.0', '9.8.1', [
+            'Cannot update Drupal core to 9.8.1 because it is not in the list of installable releases.',
+          ]),
+        ],
+      ],
+      // This case proves that an attended update to a normal non-security
+      // release is allowed regardless of how cron is configured...
+      'attended update to normal release' => [
+        ['automatic_updates.updater'],
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [],
+      ],
+      // ...and these two cases prove that an unattended update to a normal
+      // non-security release is only allowed if cron is configured to allow
+      // all updates.
+      'unattended update to normal release, security only in cron' => [
+        ['automatic_updates.cron_updater'],
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY],
+        ['drupal' => '9.8.2'],
+        [
+          $this->createValidationResult('9.8.1', '9.8.2', [
+            '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.',
+          ]),
+        ],
+      ],
+      'unattended update to normal release, all allowed in cron' => [
+        ['automatic_updates.cron_updater'],
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [],
+      ],
+      // These three cases prove that updating across minor versions of Drupal
+      // core is only allowed for attended updates when a specific configuration
+      // flag is set.
+      'unattended update to next minor' => [
+        ['automatic_updates.cron_updater'],
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [
+          $this->createValidationResult('9.7.9', '9.8.2', [
+            'Drupal cannot be automatically updated from its current version, 9.7.9, to the recommended version, 9.8.2, because automatic updates from one minor version to another are not supported during cron.',
+          ]),
+        ],
+      ],
+      'attended update to next minor not allowed' => [
+        ['automatic_updates.updater'],
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [
+          $this->createValidationResult('9.7.9', '9.8.2', [
+            'Drupal cannot be automatically updated from its current version, 9.7.9, to the recommended version, 9.8.2, because automatic updates from one minor version to another are not supported.',
+          ]),
+        ],
+      ],
+      'attended update to next minor allowed' => [
+        ['automatic_updates.updater'],
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.2'],
+        [],
+        TRUE,
+      ],
+      // Unattended updates to unstable versions are not allowed.
+      'unattended update to unstable version' => [
+        ['automatic_updates.cron_updater'],
+        '9.8.0',
+        "$metadata_dir/drupal.9.8.2-older-sec-release.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        ['drupal' => '9.8.1-beta1'],
+        [
+          $this->createValidationResult('9.8.0', '9.8.1-beta1', [
+            'Drupal cannot be automatically updated during cron to the recommended version, 9.8.1-beta1, because Automatic Updates only supports updating to stable versions during cron.',
+          ]),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation of explicitly specified target versions.
+   *
+   * @param string[] $updaters
+   *   The IDs of the updater services to test.
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string $release_metadata
+   *   The path of the core release metadata to serve to the update system.
+   * @param string[] $cron_modes
+   *   The modes for unattended updates. Can contain
+   *   \Drupal\automatic_updates\CronUpdater::SECURITY or
+   *   \Drupal\automatic_updates\CronUpdater::ALL.
+   * @param string[] $project_versions
+   *   The desired project versions that should be passed to the updater.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param bool $allow_minor_updates
+   *   (optional) Whether to allow attended updates across minor versions.
+   *   Defaults to FALSE.
+   *
+   * @dataProvider providerApi
+   */
+  public function testApi(array $updaters, string $installed_version, string $release_metadata, array $cron_modes, array $project_versions, array $expected_results, bool $allow_minor_updates = FALSE): void {
+    $this->setCoreVersion($installed_version);
+    $this->setReleaseMetadata(['drupal' => $release_metadata]);
+
+    foreach ($cron_modes as $cron_mode) {
+      $this->config('automatic_updates.settings')
+        ->set('cron', $cron_mode)
+        ->set('allow_core_minor_updates', $allow_minor_updates)
+        ->save();
+
+      foreach ($updaters as $updater) {
+        /** @var \Drupal\automatic_updates\Updater $updater */
+        $updater = $this->container->get($updater);
+
+        try {
+          $updater->begin($project_versions);
+          // Ensure that we did not, in fact, expect any errors.
+          $this->assertEmpty($expected_results);
+          // Reset the updater for the next iteration of the loop.
+          $updater->destroy();
+        }
+        catch (StageValidationException $e) {
+          $this->assertValidationResultsEqual($expected_results, $e->getResults());
+        }
+      }
+    }
+  }
+
+  /**
+   * Creates an expected validation result.
+   *
+   * Results returned from VersionPolicyValidator are always summarized in the
+   * same way, so this method ensures that expected validation results are
+   * summarized accordingly.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string $target_version
+   *   The target version of Drupal core.
+   * @param string[] $messages
+   *   The error messages that the result should contain.
+   *
+   * @return \Drupal\package_manager\ValidationResult
+   *   A validation error object with the appropriate summary.
+   */
+  private function createValidationResult(string $installed_version, string $target_version, array $messages): ValidationResult {
+    $summary = t('Updating from Drupal @installed_version to @target_version is not allowed.', [
+      '@installed_version' => $installed_version,
+      '@target_version' => $target_version,
+    ]);
+    return ValidationResult::createError($messages, $summary);
+  }
+
+}
diff --git a/tests/src/Kernel/ReleaseChooserTest.php b/tests/src/Kernel/ReleaseChooserTest.php
index 1576f53947..82c7e686da 100644
--- a/tests/src/Kernel/ReleaseChooserTest.php
+++ b/tests/src/Kernel/ReleaseChooserTest.php
@@ -34,84 +34,84 @@ class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase {
   public function providerReleases(): array {
     return [
       'installed 9.8.0, no minor support' => [
-        'chooser' => 'automatic_updates.release_chooser',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.updater',
         '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',
+        'updater' => 'automatic_updates.cron_updater',
         'minor_support' => FALSE,
         'installed_version' => '9.8.0',
-        'current_minor' => '9.8.1',
+        'current_minor' => '9.8.2',
         'next_minor' => NULL,
       ],
       'cron, installed 9.8.0, minor support' => [
-        'chooser' => 'automatic_updates.cron_release_chooser',
+        'updater' => 'automatic_updates.cron_updater',
         'minor_support' => TRUE,
         'installed_version' => '9.8.0',
-        'current_minor' => '9.8.1',
+        'current_minor' => '9.8.2',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.0, no minor support' => [
-        'chooser' => 'automatic_updates.cron_release_chooser',
+        'updater' => 'automatic_updates.cron_updater',
         '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',
+        'updater' => 'automatic_updates.cron_updater',
         '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',
+        'updater' => 'automatic_updates.cron_updater',
         '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',
+        'updater' => 'automatic_updates.cron_updater',
         'minor_support' => TRUE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
@@ -123,8 +123,8 @@ class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase {
   /**
    * Tests fetching the recommended release when an update is available.
    *
-   * @param string $chooser_service
-   *   The ID of release chooser service to use.
+   * @param string $updater_service
+   *   The ID of the updater service to use.
    * @param bool $minor_support
    *   Whether updates to the next minor will be allowed.
    * @param string $installed_version
@@ -140,13 +140,15 @@ class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase {
    * @covers ::getLatestInInstalledMinor
    * @covers ::getLatestInNextMinor
    */
-  public function testReleases(string $chooser_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void {
+  public function testReleases(string $updater_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);
-    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor());
-    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor());
+    $chooser = $this->container->get('automatic_updates.release_chooser');
+    /** @var \Drupal\automatic_updates\Updater $updater */
+    $updater = $this->container->get($updater_service);
+    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor($updater));
+    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor($updater));
   }
 
   /**
diff --git a/tests/src/Traits/VersionPolicyTestTrait.php b/tests/src/Traits/VersionPolicyTestTrait.php
new file mode 100644
index 0000000000..c86355adcd
--- /dev/null
+++ b/tests/src/Traits/VersionPolicyTestTrait.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Traits;
+
+/**
+ * Common methods for testing version policy rules.
+ */
+trait VersionPolicyTestTrait {
+
+  /**
+   * Tests that a policy rule returns a set of errors.
+   *
+   * @param object $rule
+   *   The policy rule under test.
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if it's not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $available_releases
+   *   (optional) The available releases of Drupal core, keyed by version.
+   *   Defaults to an empty array.
+   */
+  protected function assertPolicyErrors(object $rule, string $installed_version, ?string $target_version, array $expected_errors, array $available_releases = []): void {
+    $rule->setStringTranslation($this->getStringTranslationStub());
+
+    $actual_errors = array_map('strval', $rule->validate($installed_version, $target_version, $available_releases));
+    $this->assertSame($expected_errors, $actual_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php b/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
new file mode 100644
index 0000000000..4b91f51115
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\ForbidDevSnapshot;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\ForbidDevSnapshot
+ *
+ * @group automatic_updates
+ */
+class ForbidDevSnapshotTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testForbidDevSnapshot().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerForbidDevSnapshot(): array {
+    return [
+      'stable version installed' => [
+        '9.8.0',
+        [],
+      ],
+      'alpha version installed' => [
+        '9.8.0-alpha3',
+        [],
+      ],
+      'beta version installed' => [
+        '9.8.0-beta7',
+        [],
+      ],
+      'release candidate installed' => [
+        '9.8.0-rc2',
+        [],
+      ],
+      'dev snapshot installed' => [
+        '9.8.0-dev',
+        ['Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update from a dev snapshot raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerForbidDevSnapshot
+   */
+  public function testForbidDevSnapshot(string $installed_version, array $expected_errors): void {
+    $rule = new ForbidDevSnapshot();
+    $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php b/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
new file mode 100644
index 0000000000..676c8c255e
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\ForbidDowngrade;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\ForbidDowngrade
+ *
+ * @group automatic_updates
+ */
+class ForbidDowngradeTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testDowngradeForbidden().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerDowngradeForbidden(): array {
+    return [
+      'unknown target version' => [
+        '9.8.0',
+        NULL,
+        ['Update version  is lower than 9.8.0, downgrading is not supported.'],
+      ],
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'newer target version' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'older target version' => [
+        '9.8.2',
+        '9.8.0',
+        ['Update version 9.8.0 is lower than 9.8.2, downgrading is not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that downgrading always raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerDowngradeForbidden
+   */
+  public function testDowngradeForbidden(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new ForbidDowngrade();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php b/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
new file mode 100644
index 0000000000..28cb73b9be
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\ForbidMinorUpdates;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\ForbidMinorUpdates
+ *
+ * @group automatic_updates
+ */
+class ForbidMinorUpdatesTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testMinorUpdateForbidden().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerMinorUpdateForbidden(): array {
+    return [
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version older in same minor' => [
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor' => [
+        '9.8.0',
+        '9.7.2',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 9.7.2, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in newer minor' => [
+        '9.8.0',
+        '9.9.2',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 9.9.2, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in older major' => [
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 8.8.0, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in newer major' => [
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 10.8.0, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in older major and minor' => [
+        '9.8.0',
+        '8.9.9',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 8.9.9, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in newer major and minor' => [
+        '9.8.0',
+        '10.9.2',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 10.9.2, because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across minor versions raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMinorUpdateForbidden
+   */
+  public function testMinorUpdateForbidden(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new ForbidMinorUpdates();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php b/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
new file mode 100644
index 0000000000..f7b10afc45
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\MajorVersionMatch;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\MajorVersionMatch
+ *
+ * @group automatic_updates
+ */
+class MajorVersionMatchTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testMajorVersionMatch().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerMajorVersionMatch(): array {
+    return [
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version in newer minor' => [
+        '9.8.0',
+        '9.9.2',
+        [],
+      ],
+      'target version older in same minor' => [
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor' => [
+        '9.8.0',
+        '9.7.2',
+        [],
+      ],
+      'target version in newer major' => [
+        '9.8.0',
+        '10.0.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 10.0.0, because automatic updates from one major version to another are not supported.'],
+      ],
+      'target version in older major' => [
+        '9.8.0',
+        '8.9.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 8.9.0, because automatic updates from one major version to another are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across major versions raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMajorVersionMatch
+   */
+  public function testMajorVersionMatch(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new MajorVersionMatch();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php b/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
new file mode 100644
index 0000000000..be9616a7f1
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\MinorUpdatesEnabled;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\MinorUpdatesEnabled
+ *
+ * @group automatic_updates
+ */
+class MinorUpdatesEnabledTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testMinorUpdatesEnabled().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerMinorUpdatesEnabled(): array {
+    return [
+      'same versions, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'same versions, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version newer in same minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version in newer minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.9.2',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 9.9.2, because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.9.2',
+        [],
+      ],
+      'target version older in same minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version older in same minor, minor updates allowed' => [
+        TRUE,
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.7.2',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 9.7.2, because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in older minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.7.2',
+        [],
+      ],
+      'target version in older major, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 8.8.0, because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in older major, minor updates allowed' => [
+        FALSE,
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 8.8.0, because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer major, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 10.8.0, because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer major, minor updates allowed' => [
+        FALSE,
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from its current version, 9.8.0, to the recommended version, 10.8.0, because automatic updates from one minor version to another are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across minor versions depends on configuration.
+   *
+   * @param bool $allowed
+   *   Whether or not updating across minor versions is allowed.
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMinorUpdatesEnabled
+   */
+  public function testMinorUpdatesEnabled(bool $allowed, string $installed_version, ?string $target_version, array $expected_errors): void {
+    $config_factory = $this->getConfigFactoryStub([
+      'automatic_updates.settings' => [
+        'allow_core_minor_updates' => $allowed,
+      ],
+    ]);
+    $rule = new MinorUpdatesEnabled($config_factory);
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php b/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
new file mode 100644
index 0000000000..759b51c6bf
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\StableReleaseInstalled;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\StableReleaseInstalled
+ *
+ * @group automatic_updates
+ */
+class StableReleaseInstalledTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testStableReleaseInstalled().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerStableReleaseInstalled(): array {
+    return [
+      'stable version installed' => [
+        '9.8.0',
+        [],
+      ],
+      'alpha version installed' => [
+        '9.8.0-alpha3',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha3, because Automatic Updates only supports updating from stable versions during cron.'],
+      ],
+      'beta version installed' => [
+        '9.8.0-beta7',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta7, because Automatic Updates only supports updating from stable versions during cron.'],
+      ],
+      'release candidate installed' => [
+        '9.8.0-rc2',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc2, because Automatic Updates only supports updating from stable versions during cron.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update from a non-stable release raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerStableReleaseInstalled
+   */
+  public function testStableReleaseInstalled(string $installed_version, array $expected_errors): void {
+    $rule = new StableReleaseInstalled();
+    $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php b/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
new file mode 100644
index 0000000000..386a8e4fdd
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetSecurityRelease;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\TargetSecurityRelease
+ *
+ * @group automatic_updates
+ */
+class TargetSecurityReleaseTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testTargetSecurityRelease().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTargetSecurityRelease(): array {
+    return [
+      'target security release' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+            'terms' => [
+              'Release type' => ['Security update'],
+            ],
+          ]),
+        ],
+        [],
+      ],
+      'target non-security release' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+          ]),
+        ],
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0, to the recommended version, 9.8.1, because 9.8.1 is not a security release.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the target version must be a security release.
+   *
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core, keyed by version.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetSecurityRelease
+   */
+  public function testTargetSecurityRelease(array $available_releases, array $expected_errors): void {
+    $rule = new TargetSecurityRelease();
+    $this->assertPolicyErrors($rule, '9.8.0', '9.8.1', $expected_errors, $available_releases);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php b/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
new file mode 100644
index 0000000000..ea3e16ee19
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionInstallable;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionInstallable
+ *
+ * @group automatic_updates
+ */
+class TargetVersionInstallableTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testTargetVersionInstallable().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTargetVersionInstallable(): array {
+    return [
+      'no available releases' => [
+        [],
+        ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'],
+      ],
+      'unknown target' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+          ]),
+        ],
+        ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'],
+      ],
+      'valid target' => [
+        [
+          '9.8.2' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-2-release',
+            'version' => '9.8.2',
+          ]),
+        ],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the target version must be a known, installable release.
+   *
+   * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core, keyed by version.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetVersionInstallable
+   */
+  public function testTargetVersionInstallable(array $available_releases, array $expected_errors): void {
+    $rule = new TargetVersionInstallable();
+    $this->assertPolicyErrors($rule, '9.8.1', '9.8.2', $expected_errors, $available_releases);
+  }
+
+}
diff --git a/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php b/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php
new file mode 100644
index 0000000000..81fd98f44a
--- /dev/null
+++ b/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit\VersionPolicy;
+
+use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionStable;
+use Drupal\Tests\automatic_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionStable
+ *
+ * @group automatic_updates
+ */
+class TargetVersionStableTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for ::testTargetVersionStable().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTargetVersionStable(): array {
+    return [
+      'stable target version' => [
+        '9.9.0',
+        [],
+      ],
+      'dev target version' => [
+        '9.9.0-dev',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-dev, because Automatic Updates only supports updating to stable versions during cron.'],
+      ],
+      'alpha target version' => [
+        '9.9.0-alpha3',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-alpha3, because Automatic Updates only supports updating to stable versions during cron.'],
+      ],
+      'beta target version' => [
+        '9.9.0-beta7',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-beta7, because Automatic Updates only supports updating to stable versions during cron.'],
+      ],
+      'release candidate target version' => [
+        '9.9.0-rc2',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-rc2, because Automatic Updates only supports updating to stable versions during cron.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update to a non-stable version raises an error.
+   *
+   * @param string $target_version
+   *   The target version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetVersionStable
+   */
+  public function testTargetVersionStable(string $target_version, array $expected_errors): void {
+    $rule = new TargetVersionStable();
+    $this->assertPolicyErrors($rule, '9.8.0', $target_version, $expected_errors);
+  }
+
+}
-- 
GitLab