From 83415837e5f690aca25a54efb27fbc14a8eddf08 Mon Sep 17 00:00:00 2001
From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org>
Date: Fri, 2 Jun 2023 18:28:22 +0000
Subject: [PATCH] Issue #3359727 by phenaproxima, tedbow: Add new setting for
 how unattended updates will be run

---
 automatic_updates.install                     |   2 +-
 automatic_updates.module                      |   5 +-
 config/install/automatic_updates.settings.yml |   4 +-
 config/schema/automatic_updates.schema.yml    |  19 ++-
 src/Commands/AutomaticUpdatesCommands.php     |  33 ++++-
 src/CronUpdateStage.php                       |  28 ++++-
 src/Validation/AdminStatusCheckMessages.php   |  20 ++-
 src/Validation/StatusCheckRequirements.php    |  46 ++++++-
 src/Validation/StatusChecker.php              |   8 +-
 .../automatic_updates_test_cron.module        |   4 +-
 tests/src/Build/UpdateTestBase.php            |   3 +-
 .../AutomaticUpdatesFunctionalTestBase.php    |   4 +-
 tests/src/Functional/StatusCheckTest.php      | 117 +++++++++++++++---
 .../Kernel/AutomaticUpdatesKernelTestBase.php |  14 ++-
 tests/src/Kernel/CronUpdateStageTest.php      |  18 ++-
 tests/src/Kernel/HookCronTest.php             |  36 ++++++
 .../CronFrequencyValidatorTest.php            |   2 +-
 .../StatusCheck/CronServerValidatorTest.php   |   4 +-
 .../PhpExtensionsValidatorTest.php            |   6 +-
 .../StatusCheckFailureEmailTest.php           |   8 +-
 .../Kernel/StatusCheck/StatusCheckerTest.php  |   2 +-
 .../VersionPolicyValidatorTest.php            |   4 +-
 22 files changed, 328 insertions(+), 59 deletions(-)
 create mode 100644 tests/src/Kernel/HookCronTest.php

diff --git a/automatic_updates.install b/automatic_updates.install
index 935915f697..2a0c7900c9 100644
--- a/automatic_updates.install
+++ b/automatic_updates.install
@@ -30,7 +30,7 @@ function automatic_updates_requirements($phase) {
 
     // Check that site has cron updates enabled or not.
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    if (\Drupal::configFactory()->get('automatic_updates.settings')->get('cron') !== CronUpdateStage::DISABLED) {
+    if (\Drupal::configFactory()->get('automatic_updates.settings')->get('unattended.level') !== CronUpdateStage::DISABLED) {
       $requirements['automatic_updates_cron'] = [
         'title' => t('Cron installs updates automatically'),
         'severity' => SystemManager::REQUIREMENT_WARNING,
diff --git a/automatic_updates.module b/automatic_updates.module
index 9082ecd789..4e461d2195 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -165,7 +165,10 @@ function automatic_updates_module_implements_alter(&$implementations, $hook) {
 function automatic_updates_cron() {
   // @todo Refactor this after https://www.drupal.org/project/drupal/issues/2538292
   // @todo Remove this after https://www.drupal.org/project/drupal/issues/3318964
-  if (defined('MAINTENANCE_MODE') || stripos($_SERVER['PHP_SELF'], 'update.php') !== FALSE) {
+  // We don't want to run status checks if we're on the command line, because
+  // if unattended updates are configured to run via the web, running status
+  // checks on the command line could cause invalid results to get cached.
+  if (defined('MAINTENANCE_MODE') || stripos($_SERVER['PHP_SELF'], 'update.php') !== FALSE || CronUpdateStage::isCommandLine()) {
     return;
   }
 
diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml
index 2649653a1e..cb20706e33 100644
--- a/config/install/automatic_updates.settings.yml
+++ b/config/install/automatic_updates.settings.yml
@@ -1,3 +1,5 @@
-cron: disable
+unattended:
+  method: web
+  level: disable
 allow_core_minor_updates: false
 status_check_mail: errors_only
diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml
index dddf10e819..9542782743 100644
--- a/config/schema/automatic_updates.schema.yml
+++ b/config/schema/automatic_updates.schema.yml
@@ -2,9 +2,22 @@ automatic_updates.settings:
   type: config_object
   label: 'Automatic Updates settings'
   mapping:
-    cron:
-      type: string
-      label: 'Enable automatic updates during cron'
+    unattended:
+      type: mapping
+      label: 'Settings for unattended update'
+      mapping:
+        method:
+          type: string
+          label: 'Method of running unattended updates'
+          constraints:
+            NotNull: []
+            Choice: [console, web]
+        level:
+          type: string
+          label: 'Which level of unattended updates to perform'
+          constraints:
+            NotNull: []
+            Choice: [disable, security, patch]
     cron_port:
       type: integer
       label: 'Port to use for finalization sub-request'
diff --git a/src/Commands/AutomaticUpdatesCommands.php b/src/Commands/AutomaticUpdatesCommands.php
index e15219919e..4646cde772 100644
--- a/src/Commands/AutomaticUpdatesCommands.php
+++ b/src/Commands/AutomaticUpdatesCommands.php
@@ -5,6 +5,9 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates\Commands;
 
 use Drupal\automatic_updates\DrushUpdateStage;
+use Drupal\automatic_updates\StatusCheckMailer;
+use Drupal\automatic_updates\Validation\StatusChecker;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drush\Commands\DrushCommands;
 
 /**
@@ -22,8 +25,19 @@ final class AutomaticUpdatesCommands extends DrushCommands {
    *
    * @param \Drupal\automatic_updates\DrushUpdateStage $stage
    *   The console cron updater service.
+   * @param \Drupal\automatic_updates\Validation\StatusChecker $statusChecker
+   *   The status checker service.
+   * @param \Drupal\automatic_updates\StatusCheckMailer $statusCheckMailer
+   *   The status check mailer service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+   *   The config factory service.
    */
-  public function __construct(private readonly DrushUpdateStage $stage) {
+  public function __construct(
+    private readonly DrushUpdateStage $stage,
+    private readonly StatusChecker $statusChecker,
+    private readonly StatusCheckMailer $statusCheckMailer,
+    private readonly ConfigFactoryInterface $configFactory,
+  ) {
     parent::__construct();
   }
 
@@ -60,6 +74,7 @@ final class AutomaticUpdatesCommands extends DrushCommands {
 
       $io->info('Running post-apply tasks and final clean-up...');
       $this->stage->handlePostApply($options['stage-id'], $options['from-version'], $options['to-version']);
+      $this->runStatusChecks();
     }
     else {
       if ($this->stage->getMode() === DrushUpdateStage::DISABLED) {
@@ -75,8 +90,24 @@ final class AutomaticUpdatesCommands extends DrushCommands {
       }
       else {
         $io->info("There is no Drupal core update available.");
+        $this->runStatusChecks();
       }
     }
   }
 
+  /**
+   * Runs status checks, and sends failure notifications if necessary.
+   */
+  private function runStatusChecks(): void {
+    $method = $this->configFactory->get('automatic_updates.settings')
+      ->get('unattended.method');
+
+    // To ensure consistent results, only run the status checks if we're
+    // explicitly configured to do unattended updates on the command line.
+    if ($method === 'console') {
+      $last_results = $this->statusChecker->getResults();
+      $this->statusCheckMailer->sendFailureNotifications($last_results, $this->statusChecker->run()->getResults());
+    }
+  }
+
 }
diff --git a/src/CronUpdateStage.php b/src/CronUpdateStage.php
index 365322f6b3..533256f783 100644
--- a/src/CronUpdateStage.php
+++ b/src/CronUpdateStage.php
@@ -40,6 +40,13 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  */
 class CronUpdateStage extends UpdateStage implements CronInterface {
 
+  /**
+   * The current interface between PHP and the server.
+   *
+   * @var string
+   */
+  private static $serverApi = PHP_SAPI;
+
   /**
    * All automatic updates are disabled.
    *
@@ -121,6 +128,16 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
     parent::__construct($composerInspector, $pathLocator, $beginner, $stager, $committer, $fileSystem, $eventDispatcher, $tempStoreFactory, $time, $pathFactory, $failureMarker);
   }
 
+  /**
+   * Indicates if we are currently running at the command line.
+   *
+   * @return bool
+   *   TRUE if we are running at the command line, otherwise FALSE.
+   */
+  final public static function isCommandLine(): bool {
+    return self::$serverApi === 'cli';
+  }
+
   /**
    * Handles updates during cron.
    *
@@ -392,7 +409,7 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
    *     allowed during cron.
    */
   final public function getMode(): string {
-    $mode = $this->configFactory->get('automatic_updates.settings')->get('cron');
+    $mode = $this->configFactory->get('automatic_updates.settings')->get('unattended.level');
     return $mode ?: static::SECURITY;
   }
 
@@ -400,7 +417,14 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
    * {@inheritdoc}
    */
   public function run() {
-    if ($this->handleCron()) {
+    $method = $this->configFactory->get('automatic_updates.settings')
+      ->get('unattended.method');
+
+    // If we are configured to run updates via the web, and we're actually being
+    // accessed via the web (i.e., anything that isn't the command line), go
+    // ahead and try to do the update. In all other circumstances, just run the
+    // normal cron handler.
+    if ($method === 'web' && !self::isCommandLine() && $this->handleCron()) {
       return TRUE;
     }
     return $this->inner->run();
diff --git a/src/Validation/AdminStatusCheckMessages.php b/src/Validation/AdminStatusCheckMessages.php
index acc27ca9ab..4e0d9df309 100644
--- a/src/Validation/AdminStatusCheckMessages.php
+++ b/src/Validation/AdminStatusCheckMessages.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates\Validation;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Messenger\MessengerTrait;
@@ -49,6 +50,8 @@ final class AdminStatusCheckMessages implements ContainerInjectionInterface {
    *   The cron update stage service.
    * @param \Drupal\Core\Render\RendererInterface $renderer
    *   The renderer service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+   *   The config factory service.
    */
   public function __construct(
     private readonly StatusChecker $statusChecker,
@@ -56,7 +59,8 @@ final class AdminStatusCheckMessages implements ContainerInjectionInterface {
     private readonly AccountProxyInterface $currentUser,
     private readonly CurrentRouteMatch $currentRouteMatch,
     private readonly CronUpdateStage $stage,
-    private readonly RendererInterface $renderer
+    private readonly RendererInterface $renderer,
+    private readonly ConfigFactoryInterface $configFactory
   ) {}
 
   /**
@@ -69,7 +73,8 @@ final class AdminStatusCheckMessages implements ContainerInjectionInterface {
       $container->get('current_user'),
       $container->get('current_route_match'),
       $container->get('automatic_updates.cron_update_stage'),
-      $container->get('renderer')
+      $container->get('renderer'),
+      $container->get('config.factory')
     );
   }
 
@@ -81,12 +86,21 @@ final class AdminStatusCheckMessages implements ContainerInjectionInterface {
       return;
     }
     if ($this->statusChecker->getResults() === NULL) {
+      $method = $this->configFactory->get('automatic_updates.settings')
+        ->get('unattended.method');
+
       $checker_url = Url::fromRoute('automatic_updates.status_check')->setOption('query', $this->getDestinationArray());
-      if ($checker_url->access()) {
+      if ($method === 'web' && $checker_url->access()) {
         $this->messenger()->addError($this->t('Your site has not recently run an update readiness check. <a href=":url">Rerun readiness checks now.</a>', [
           ':url' => $checker_url->toString(),
         ]));
       }
+      elseif ($method === 'console') {
+        // @todo Link to the documentation on how to set up unattended updates
+        //   via the terminal in https://drupal.org/i/3362695.
+        $message = $this->t('Unattended updates are configured to run via the console, but not appear to have run recently.');
+        $this->messenger()->addError($message);
+      }
     }
     else {
       // Display errors, if there are any. If there aren't, then display
diff --git a/src/Validation/StatusCheckRequirements.php b/src/Validation/StatusCheckRequirements.php
index 573d859389..bc005d79ef 100644
--- a/src/Validation/StatusCheckRequirements.php
+++ b/src/Validation/StatusCheckRequirements.php
@@ -4,6 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\automatic_updates\Validation;
 
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Datetime\DateFormatterInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
@@ -33,10 +34,13 @@ final class StatusCheckRequirements implements ContainerInjectionInterface {
    *   The status checker service.
    * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
    *   The date formatter service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+   *   The config factory service.
    */
   public function __construct(
     private readonly StatusChecker $statusChecker,
-    private readonly DateFormatterInterface $dateFormatter
+    private readonly DateFormatterInterface $dateFormatter,
+    private readonly ConfigFactoryInterface $configFactory,
   ) {}
 
   /**
@@ -45,10 +49,23 @@ final class StatusCheckRequirements implements ContainerInjectionInterface {
   public static function create(ContainerInterface $container): self {
     return new static(
       $container->get('automatic_updates.status_checker'),
-      $container->get('date.formatter')
+      $container->get('date.formatter'),
+      $container->get('config.factory'),
     );
   }
 
+  /**
+   * Returns the method used to run unattended updates.
+   *
+   * @return string
+   *   The method used to run unattended updates. Will be either 'console' or
+   *   'web'.
+   */
+  private function getMethod(): string {
+    return $this->configFactory->get('automatic_updates.settings')
+      ->get('unattended.method');
+  }
+
   /**
    * Gets requirements arrays as specified in hook_requirements().
    *
@@ -56,8 +73,26 @@ final class StatusCheckRequirements implements ContainerInjectionInterface {
    *   Requirements arrays as specified by hook_requirements().
    */
   public function getRequirements(): array {
-    $results = $this->statusChecker->run()->getResults();
     $requirements = [];
+
+    $results = $this->statusChecker->getResults();
+    // If unattended updates are run on the terminal, we don't want to do the
+    // status check right now, since running them over the web may yield
+    // inaccurate or irrelevant results. The console command runs status checks,
+    // so if there are no results, we can assume it has not been run in a while,
+    // and raise an error about that.
+    if (is_null($results) && $this->getMethod() === 'console') {
+      $requirements['automatic_updates_status_check_console_command_not_run'] = [
+        'title' => $this->t('Update readiness checks'),
+        'severity' => SystemManager::REQUIREMENT_ERROR,
+        // @todo Link to the documentation on how to set up unattended updates
+        //   via the terminal in https://drupal.org/i/3362695.
+        'value' => $this->t('Unattended updates are configured to run via the console, but do not appear to have run recently.'),
+      ];
+      return $requirements;
+    }
+
+    $results ??= $this->statusChecker->run()->getResults();
     if (empty($results)) {
       $requirements['automatic_updates_status_check'] = [
         'title' => $this->t('Update readiness checks'),
@@ -144,6 +179,11 @@ final class StatusCheckRequirements implements ContainerInjectionInterface {
    *   NULL.
    */
   protected function createRunLink(): ?TranslatableMarkup {
+    // Only show this link if unattended updates are being run over the web.
+    if ($this->getMethod() !== 'web') {
+      return NULL;
+    }
+
     $status_check_url = Url::fromRoute('automatic_updates.status_check');
     if ($status_check_url->access()) {
       return $this->t(
diff --git a/src/Validation/StatusChecker.php b/src/Validation/StatusChecker.php
index 2a358b5bac..c69d463c38 100644
--- a/src/Validation/StatusChecker.php
+++ b/src/Validation/StatusChecker.php
@@ -154,13 +154,15 @@ final class StatusChecker implements EventSubscriberInterface {
       $this->clearStoredResults();
     }
     elseif ($config->getName() === 'automatic_updates.settings') {
+      // If anything about how we run unattended updates has changed, clear the
+      // stored results, since they can be affected by these settings.
+      if ($event->isChanged('unattended')) {
+        $this->clearStoredResults();
+      }
       // We only send status check failure notifications if unattended updates
       // are enabled. If notifications were previously disabled but have been
       // re-enabled, or their sensitivity level has changed, clear the stored
       // results so that we'll send accurate notifications next time cron runs.
-      if ($event->isChanged('cron') && $config->getOriginal('cron') === CronUpdateStage::DISABLED) {
-        $this->clearStoredResults();
-      }
       elseif ($event->isChanged('status_check_mail') && $config->get('status_check_mail') !== StatusCheckMailer::DISABLED) {
         $this->clearStoredResults();
       }
diff --git a/tests/modules/automatic_updates_test_cron/automatic_updates_test_cron.module b/tests/modules/automatic_updates_test_cron/automatic_updates_test_cron.module
index 2b7f3f485c..901db03a19 100644
--- a/tests/modules/automatic_updates_test_cron/automatic_updates_test_cron.module
+++ b/tests/modules/automatic_updates_test_cron/automatic_updates_test_cron.module
@@ -37,7 +37,7 @@ function automatic_updates_test_cron_form_update_settings_alter(array &$form, Fo
       CronUpdateStage::ALL => t('All supported updates'),
       CronUpdateStage::SECURITY => t('Security updates only'),
     ],
-    '#default_value' => \Drupal::config('automatic_updates.settings')->get('cron'),
+    '#default_value' => \Drupal::config('automatic_updates.settings')->get('unattended.level'),
     '#description' => t(
       'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.',
       [
@@ -58,6 +58,6 @@ function automatic_updates_test_cron_form_update_settings_alter(array &$form, Fo
 function _automatic_updates_test_cron_update_settings_form_submit(array &$form, FormStateInterface $form_state) {
   \Drupal::configFactory()
     ->getEditable('automatic_updates.settings')
-    ->set('cron', $form_state->getValue('automatic_updates_cron'))
+    ->set('unattended.level', $form_state->getValue('automatic_updates_cron'))
     ->save();
 }
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index 6ff090d657..c507716688 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -21,7 +21,7 @@ abstract class UpdateTestBase extends TemplateProjectTestBase {
     parent::createTestProject($template);
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
     $code = <<<END
-\$config['automatic_updates.settings']['cron'] = 'security';
+\$config['automatic_updates.settings']['unattended']['level'] = 'security';
 END;
     $this->writeSettings($code);
     // Install Automatic Updates, and other modules needed for testing.
@@ -69,6 +69,7 @@ END;
     $this->visit('/admin/reports/status');
     $mink = $this->getMink();
     $page = $mink->getSession()->getPage();
+    $page->clickLink('Rerun readiness checks');
 
     $readiness_check_summaries = $page->findAll('css', 'summary:contains("Update readiness checks")');
     // There should always either be the summary section indicating the site is
diff --git a/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php b/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
index 62b8461162..f518f16a8d 100644
--- a/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
+++ b/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
@@ -39,7 +39,9 @@ abstract class AutomaticUpdatesFunctionalTestBase extends BrowserTestBase {
     parent::setUp();
     $this->useFixtureDirectoryAsActive(__DIR__ . '/../../../package_manager/tests/fixtures/fake_site');
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::SECURITY)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->save();
   }
 
   /**
diff --git a/tests/src/Functional/StatusCheckTest.php b/tests/src/Functional/StatusCheckTest.php
index 3f9fa4505a..12aa80d0bc 100644
--- a/tests/src/Functional/StatusCheckTest.php
+++ b/tests/src/Functional/StatusCheckTest.php
@@ -127,6 +127,7 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
    */
   public function testStatusChecksOnStatusReport(): void {
     $assert = $this->assertSession();
+    $page = $this->getSession()->getPage();
 
     // Ensure automated_cron is disabled before installing automatic_updates.
     // This ensures we are testing that automatic_updates runs the checkers when
@@ -186,49 +187,50 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     $this->drupalGet('admin/reports/status');
     $this->assertErrors($expected_results);
 
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('/admin/reports/status');
+
     $expected_results = [
       'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
       'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
     ];
     TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class);
-    // Confirm a new message is displayed if the page is reloaded.
-    $this->getSession()->reload();
-    // On the status page, we should see the summaries and messages, even if
-    // there is only 1 message.
-    $this->assertErrors([$expected_results['error']]);
-    $this->assertWarnings([$expected_results['warning']]);
+    $page->clickLink('Rerun readiness checks');
+    // We should see the summaries and messages, even if there's only 1 message.
+    $this->assertErrors([$expected_results['error']], TRUE);
+    $this->assertWarnings([$expected_results['warning']], TRUE);
 
     // If there's a result with only one message, but no summary, ensure that
     // message is displayed.
     $result = ValidationResult::createError([t('A lone message, with no summary.')]);
     TestSubscriber1::setTestResult([$result], StatusCheckEvent::class);
-    $this->getSession()->reload();
-    $this->assertErrors([$result]);
+    $page->clickLink('Rerun readiness checks');
+    $this->assertErrors([$result], TRUE);
 
     $expected_results = [
       'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2),
       'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2),
     ];
     TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class);
-    $this->getSession()->reload();
-    // Confirm that both messages and summaries will be displayed on status
-    // report when there multiple messages.
-    $this->assertErrors([$expected_results['error']]);
-    $this->assertWarnings([$expected_results['warning']]);
+    $page->clickLink('Rerun readiness checks');
+    // Confirm that both messages and summaries will be displayed when there are
+    // multiple messages.
+    $this->assertErrors([$expected_results['error']], TRUE);
+    $this->assertWarnings([$expected_results['warning']], TRUE);
 
     $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2)];
     TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class);
-    $this->getSession()->reload();
+    $page->clickLink('Rerun readiness checks');
     $assert->pageTextContainsOnce('Update readiness checks');
     // Confirm that warnings will display on the status report if there are no
     // errors.
-    $this->assertWarnings($expected_results);
+    $this->assertWarnings($expected_results, TRUE);
 
     $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)];
     TestSubscriber1::setTestResult($expected_results, StatusCheckEvent::class);
-    $this->getSession()->reload();
+    $page->clickLink('Rerun readiness checks');
     $assert->pageTextContainsOnce('Update readiness checks');
-    $this->assertWarnings($expected_results);
+    $this->assertWarnings($expected_results, TRUE);
   }
 
   /**
@@ -381,7 +383,9 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
 
     // Confirm status check messages are not displayed when cron updates are
     // disabled.
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::DISABLED)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::DISABLED)
+      ->save();
     $this->drupalGet('admin/structure');
     $this->checkForMetaRefresh();
     $assert->pageTextNotContains(static::$warningsExplanation);
@@ -403,7 +407,9 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     // the functionality to retrieve our fake release history metadata.
     $this->container->get('module_installer')->install(['automatic_updates', 'automatic_updates_test']);
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::SECURITY)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->save();
     $this->drupalGet('admin/reports/status');
     $this->assertNoErrors(TRUE);
 
@@ -578,6 +584,79 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     $assert_session->pageTextContains($no_results_message);
   }
 
+  /**
+   * Tests that the status report shows cached status check results.
+   */
+  public function testStatusReportShowsCachedResults(): void {
+    $session = $this->getSession();
+    $this->drupalLogin($this->checkerRunnerUser);
+
+    $this->container->get('module_installer')->install([
+      'automatic_updates',
+      'automatic_updates_test',
+    ]);
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    // Clear stored results that were collected when the module was installed.
+    $this->container->get('automatic_updates.status_checker')
+      ->clearStoredResults();
+
+    // Flag a validation error, whose summary will be displayed in the messages
+    // area.
+    $result = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
+    TestSubscriber1::setTestResult([$result], StatusCheckEvent::class);
+
+    $this->drupalGet('/admin/reports/status');
+    $this->assertErrors([$result], TRUE);
+
+    // Clear the result, and ensure that it's still visible because it is
+    // cached.
+    TestSubscriber::setTestResult(NULL, StatusCheckEvent::class);
+    $session->reload();
+    $this->assertErrors([$result], TRUE);
+
+    // If unattended updates are configured to run via the command line, we
+    // should see a warning that the status checks have not run recently. This
+    // is because changing the configuration clears the cached results, since
+    // they may be affected by the change.
+    // @see \Drupal\automatic_updates\Validation\StatusChecker::onConfigSave()
+    $this->config('automatic_updates.settings')
+      ->set('unattended.method', 'console')
+      ->save();
+    $session->reload();
+    $assert_session = $this->assertSession();
+    $assert_session->pageTextContainsOnce('Unattended updates are configured to run via the console, but do not appear to have run recently.');
+    $assert_session->pageTextNotContains((string) $result->messages[0]);
+  }
+
+  /**
+   * Tests the status checks when unattended updates are run via the console.
+   */
+  public function testUnattendedUpdatesRunFromConsole(): void {
+    $this->container->get('module_installer')->install(['automatic_updates']);
+    $this->container = $this->container->get('kernel')->getContainer();
+
+    // Clear stored results that were collected when the module was installed.
+    $this->container->get('automatic_updates.status_checker')
+      ->clearStoredResults();
+
+    $this->config('automatic_updates.settings')
+      ->set('unattended.method', 'console')
+      ->save();
+
+    // If we visit the status report, we should see an error requirement because
+    // unattended updates are configured to run via the terminal, and there are
+    // no stored status check results, which means that the console command has
+    // probably not run recently (or ever).
+    $this->drupalGet('/admin/reports/status');
+    $this->assertRequirement('error', 'Unattended updates are configured to run via the console, but do not appear to have run recently.', [], FALSE);
+
+    // We should see a similar message on any other admin page.
+    $this->drupalGet('/admin/structure');
+    $this->assertSession()
+      ->statusMessageContains('Unattended updates are configured to run via the console, but not appear to have run recently.', 'error');
+  }
+
   /**
    * Asserts that the status check requirement displays no errors or warnings.
    *
diff --git a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
index 9ac2612e13..c4765f78e2 100644
--- a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
+++ b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
@@ -51,7 +51,12 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
     parent::setUp();
     // Enable cron updates, which will eventually be the default.
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::SECURITY)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended', [
+        'method' => 'web',
+        'level' => CronUpdateStage::SECURITY,
+      ])
+      ->save();
 
     // By default, pretend we're running Drupal core 9.8.0 and a non-security
     // update to 9.8.1 is available.
@@ -62,6 +67,13 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
     // from a sane state.
     // @see \Drupal\automatic_updates\Validator\CronFrequencyValidator
     $this->container->get('state')->set('system.cron_last', time());
+
+    // Cron updates are not done when running at the command line, so override
+    // our cron handler's PHP_SAPI constant to a valid value that isn't `cli`.
+    // The choice of `cgi-fcgi` is arbitrary; see
+    // https://www.php.net/php_sapi_name for some valid values of PHP_SAPI.
+    $property = new \ReflectionProperty(CronUpdateStage::class, 'serverApi');
+    $property->setValue(NULL, 'cgi-fcgi');
   }
 
   /**
diff --git a/tests/src/Kernel/CronUpdateStageTest.php b/tests/src/Kernel/CronUpdateStageTest.php
index 7b2d32f4f1..b13e2d0ee3 100644
--- a/tests/src/Kernel/CronUpdateStageTest.php
+++ b/tests/src/Kernel/CronUpdateStageTest.php
@@ -156,7 +156,9 @@ class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
     $this->setReleaseMetadata($release_data);
     $this->setCoreVersion('9.8.0');
     update_get_available(TRUE);
-    $this->config('automatic_updates.settings')->set('cron', $setting)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', $setting)
+      ->save();
 
     // Since we're just trying to ensure that all of Package Manager's services
     // are called as expected, disable validation by replacing the event
@@ -260,7 +262,9 @@ class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
     }
     $this->installConfig('automatic_updates');
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::SECURITY)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->save();
     // Ensure that there is a security release to which we should update.
     $this->setReleaseMetadata([
       'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
@@ -368,7 +372,9 @@ class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
    * Tests stage is not destroyed if another update is applying.
    */
   public function testStageNotDestroyedIfApplying(): void {
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::ALL)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
     $this->setReleaseMetadata([
       'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
     ]);
@@ -406,7 +412,9 @@ class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
    * Tests stage is not destroyed if not available and site is on secure version.
    */
   public function testStageNotDestroyedIfSecure(): void {
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::ALL)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
     $this->setReleaseMetadata([
       'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml",
     ]);
@@ -548,7 +556,7 @@ END;
       'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml',
     ]);
     $this->config('automatic_updates.settings')
-      ->set('cron', CronUpdateStage::ALL)
+      ->set('unattended.level', CronUpdateStage::ALL)
       ->save();
 
     $error = ValidationResult::createError([
diff --git a/tests/src/Kernel/HookCronTest.php b/tests/src/Kernel/HookCronTest.php
new file mode 100644
index 0000000000..b4b2e845f2
--- /dev/null
+++ b/tests/src/Kernel/HookCronTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Drupal\automatic_updates\Validation\StatusChecker;
+
+/**
+ * @group automatic_updates
+ */
+class HookCronTest extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates'];
+
+  /**
+   * Tests that our cron hook does not run if we're at the command line.
+   */
+  public function testCronHookBypassedAtCommandLine(): void {
+    if (PHP_SAPI !== 'cli') {
+      $this->markTestSkipped('This test requires that PHP be running at the command line.');
+    }
+
+    // The status check should not have run yet.
+    /** @var \Drupal\automatic_updates\Validation\StatusChecker $status_checker */
+    $status_checker = $this->container->get(StatusChecker::class);
+    $this->assertNull($status_checker->getLastRunTime());
+
+    // Since we're at the command line, status checks should still not run, even
+    // if we do run cron.
+    $this->container->get('cron')->run();
+    $this->assertNull($status_checker->getResults());
+  }
+
+}
diff --git a/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php b/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
index c601479274..bbd2e7dfab 100644
--- a/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
@@ -41,7 +41,7 @@ class CronFrequencyValidatorTest extends AutomaticUpdatesKernelTestBase {
    */
   public function testNoValidationIfCronDisabled(): void {
     $this->config('automatic_updates.settings')
-      ->set('cron', CronUpdateStage::DISABLED)
+      ->set('unattended.level', CronUpdateStage::DISABLED)
       ->save();
 
     $validator = new class (
diff --git a/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php b/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php
index 0c036ee256..9c7fa9026c 100644
--- a/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php
@@ -111,7 +111,7 @@ class CronServerValidatorTest extends AutomaticUpdatesKernelTestBase {
     $property->setValue(NULL, $server_api);
 
     $this->config('automatic_updates.settings')
-      ->set('cron', $cron_mode)
+      ->set('unattended.level', $cron_mode)
       ->set('cron_port', $alternate_port ? 2501 : 0)
       ->save();
 
@@ -169,7 +169,7 @@ class CronServerValidatorTest extends AutomaticUpdatesKernelTestBase {
       ->addLogger($logger);
 
     $this->config('automatic_updates.settings')
-      ->set('cron', $cron_mode)
+      ->set('unattended.level', $cron_mode)
       ->save();
 
     // Add a listener to change the $server_api and $alternate_port settings
diff --git a/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php b/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
index edcbbc3790..c8917b7a36 100644
--- a/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
@@ -44,12 +44,12 @@ class PhpExtensionsValidatorTest extends AutomaticUpdatesKernelTestBase {
 
     // If unattended updates are disabled, we should only see a warning from
     // Package Manager.
-    $config->set('cron', CronUpdateStage::DISABLED)->save();
+    $config->set('unattended.level', CronUpdateStage::DISABLED)->save();
     $this->assertCheckerResultsFromManager([$warning_result], TRUE);
 
     // The parent class' setUp() method simulates an available security update,
     // so ensure that the cron update stage will try to update to it.
-    $config->set('cron', CronUpdateStage::SECURITY)->save();
+    $config->set('unattended.level', CronUpdateStage::SECURITY)->save();
 
     // If unattended updates are enabled, we should see an error from Automatic
     // Updates.
@@ -77,7 +77,7 @@ class PhpExtensionsValidatorTest extends AutomaticUpdatesKernelTestBase {
     // The parent class' setUp() method simulates an available security
     // update, so ensure that the cron update stage will try to update to it.
     $this->config('automatic_updates.settings')
-      ->set('cron', CronUpdateStage::SECURITY)
+      ->set('unattended.level', CronUpdateStage::SECURITY)
       ->save();
 
     $logger = new TestLogger();
diff --git a/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php b/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php
index 4b143af32d..25ff4cdeaa 100644
--- a/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php
+++ b/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php
@@ -54,7 +54,9 @@ class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
 
     $this->installConfig('automatic_updates');
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
-    $this->config('automatic_updates.settings')->set('cron', CronUpdateStage::SECURITY)->save();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->save();
     $this->setUpEmailRecipients();
 
     // Allow stored available update data to live for a very, very long time.
@@ -212,7 +214,7 @@ END;
 
     // If we disable unattended updates entirely and flag a new error, they
     // should not be e-mailed.
-    $config->set('cron', CronUpdateStage::DISABLED)->save();
+    $config->set('unattended.level', CronUpdateStage::DISABLED)->save();
     $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
     TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
     $this->runCron();
@@ -220,7 +222,7 @@ END;
 
     // If we re-enable unattended updates, they should be emailed again, even if
     // the results haven't changed.
-    $config->set('cron', CronUpdateStage::ALL)->save();
+    $config->set('unattended.level', CronUpdateStage::ALL)->save();
     $this->runCron();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
diff --git a/tests/src/Kernel/StatusCheck/StatusCheckerTest.php b/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
index 0837b7108b..8ebddb72ae 100644
--- a/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
+++ b/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
@@ -212,7 +212,7 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
     // By default, updates will be enabled on cron.
     $this->assertInstanceOf(CronUpdateStage::class, $stage);
     $this->config('automatic_updates.settings')
-      ->set('cron', CronUpdateStage::DISABLED)
+      ->set('unattended.level', CronUpdateStage::DISABLED)
       ->save();
     $this->container->get(StatusChecker::class)->run();
     $this->assertInstanceOf(UpdateStage::class, $stage);
diff --git a/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php b/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
index c9c8c2e0bc..a8e64641e8 100644
--- a/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
@@ -198,7 +198,7 @@ class VersionPolicyValidatorTest extends AutomaticUpdatesKernelTestBase {
 
     foreach ($cron_modes as $cron_mode) {
       $this->config('automatic_updates.settings')
-        ->set('cron', $cron_mode)
+        ->set('unattended.level', $cron_mode)
         ->set('allow_core_minor_updates', $allow_minor_updates)
         ->save();
 
@@ -420,7 +420,7 @@ class VersionPolicyValidatorTest extends AutomaticUpdatesKernelTestBase {
 
     foreach ($cron_modes as $cron_mode) {
       $this->config('automatic_updates.settings')
-        ->set('cron', $cron_mode)
+        ->set('unattended.level', $cron_mode)
         ->set('allow_core_minor_updates', $allow_minor_updates)
         ->save();
 
-- 
GitLab