From fc72c0cd3c422a8f7328d6508ba42bb6ec4e48dc Mon Sep 17 00:00:00 2001
From: Ted Bowman <ted+git@tedbow.com>
Date: Wed, 14 Jun 2023 09:42:29 -0400
Subject: [PATCH] debugging to be removed

---
 package_manager/src/DebuggerTrait.php        |  11 +
 tests/src/Functional/CronUpdateStageTest.php | 653 +++++++++++++++++++
 2 files changed, 664 insertions(+)
 create mode 100644 package_manager/src/DebuggerTrait.php
 create mode 100644 tests/src/Functional/CronUpdateStageTest.php

diff --git a/package_manager/src/DebuggerTrait.php b/package_manager/src/DebuggerTrait.php
new file mode 100644
index 0000000000..87155e1880
--- /dev/null
+++ b/package_manager/src/DebuggerTrait.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Drupal\package_manager;
+
+trait DebuggerTrait {
+
+  protected function debugOut($string, $flags = FILE_APPEND) {
+    file_put_contents("/Users/ted.bowman/sites/drush.txt", "\n" . $string, $flags);
+  }
+
+}
diff --git a/tests/src/Functional/CronUpdateStageTest.php b/tests/src/Functional/CronUpdateStageTest.php
new file mode 100644
index 0000000000..3378f8bbec
--- /dev/null
+++ b/tests/src/Functional/CronUpdateStageTest.php
@@ -0,0 +1,653 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\automatic_updates\Functional;
+
+use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\Url;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostCreateEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreDestroyEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Exception\StageEventException;
+use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\package_manager_bypass\LoggingCommitter;
+use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
+use Drupal\Tests\package_manager\Kernel\TestStage;
+use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drush\TestTraits\DrushTestTrait;
+use Prophecy\Argument;
+use ColinODell\PsrTestLogger\TestLogger;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * @covers \Drupal\automatic_updates\CronUpdateStage
+ * @covers \automatic_updates_test_cron_form_update_settings_alter
+ * @group automatic_updates
+ * @internal
+ */
+class CronUpdateStageTest extends AutomaticUpdatesFunctionalTestBase {
+
+  use DrushTestTrait;
+  use EmailNotificationsTestTrait;
+  use PackageManagerBypassTestTrait;
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'automatic_updates_test',
+    'user',
+    'common_test_cron_helper',
+    'dblog',
+  ];
+
+  /**
+   * The test logger.
+   *
+   * @var \ColinODell\PsrTestLogger\TestLogger
+   */
+  private $logger;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->logger = new TestLogger();
+
+    $this->setUpEmailRecipients();
+    $this->assertRegularCronRun(FALSE);
+    $this->drupalLogin($this->createUser(['access site reports']));
+
+    $this->mockActiveCoreVersion('9.8.0');
+  }
+  /**
+   * Data provider for testUpdateStageCalled().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerUpdateStageCalled(): array {
+    $fixture_dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
+
+    return [
+      'disabled, normal release' => [
+        CronUpdateStage::DISABLED,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        FALSE,
+      ],
+      'disabled, security release' => [
+        CronUpdateStage::DISABLED,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        FALSE,
+      ],
+      'security only, security release' => [
+        CronUpdateStage::SECURITY,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        TRUE,
+      ],
+      'security only, normal release' => [
+        CronUpdateStage::SECURITY,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        FALSE,
+      ],
+      'enabled, normal release' => [
+        CronUpdateStage::ALL,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        TRUE,
+      ],
+      'enabled, security release' => [
+        CronUpdateStage::ALL,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the cron handler calls the update stage as expected.
+   *
+   * @param string $setting
+   *   Whether automatic updates should be enabled during cron. Possible values
+   *   are 'disable', 'security', and 'patch'.
+   * @param array $release_data
+   *   If automatic updates are enabled, the path of the fake release metadata
+   *   that should be served when fetching information on available updates,
+   *   keyed by project name.
+   * @param bool $will_update
+   *   Whether an update should be performed, given the previous two arguments.
+   *
+   * @dataProvider providerUpdateStageCalled
+   */
+  public function testUpdateStageCalled(string $setting, array $release_data, bool $will_update): void {
+    $version = strpos($release_data['drupal'], '9.8.2') ? '9.8.2' : '9.8.1';
+    if ($will_update) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion($version);
+    }
+    // Our form alter does not refresh information on available updates, so
+    // ensure that the appropriate update data is loaded beforehand.
+    $this->setReleaseMetadata($release_data);
+    $this->setCoreVersion('9.8.0');
+    update_get_available(TRUE);
+    $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
+    // dispatcher with a dummy version.
+    $event_dispatcher = $this->prophesize(EventDispatcherInterface::class);
+    $event_dispatcher->dispatch(Argument::type('object'))->willReturnArgument(0);
+    $this->container->set('event_dispatcher', $event_dispatcher->reveal());
+
+    // Run cron and ensure that Package Manager's services were called or
+    // bypassed depending on configuration.
+    $this->container->get('cron')->run();
+
+    $will_update = (int) $will_update;
+    $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments());
+    // If updates happen, there will be at least two calls to the stager: one
+    // to change the runtime constraints in composer.json, and another to
+    // actually update the installed dependencies. If there are any core
+    // dev requirements (such as `drupal/core-dev`), the stager will also be
+    // called to update the dev constraints in composer.json.
+    $this->assertGreaterThanOrEqual($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
+    $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
+  }
+
+  /**
+   * Data provider for testStageDestroyedOnError().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerStageDestroyedOnError(): array {
+    return [
+      'pre-create exception' => [
+        PreCreateEvent::class,
+        'Exception',
+      ],
+      'post-create exception' => [
+        PostCreateEvent::class,
+        'Exception',
+      ],
+      'pre-require exception' => [
+        PreRequireEvent::class,
+        'Exception',
+      ],
+      'post-require exception' => [
+        PostRequireEvent::class,
+        'Exception',
+      ],
+      'pre-apply exception' => [
+        PreApplyEvent::class,
+        'Exception',
+      ],
+      'post-apply exception' => [
+        PostApplyEvent::class,
+        'Exception',
+      ],
+      'pre-destroy exception' => [
+        PreDestroyEvent::class,
+        'Exception',
+      ],
+      'post-destroy exception' => [
+        PostDestroyEvent::class,
+        'Exception',
+      ],
+      // Only pre-operation events can add validation results.
+      // @see \Drupal\package_manager\Event\PreOperationStageEvent
+      // @see \Drupal\package_manager\Stage::dispatch()
+      'pre-create validation error' => [
+        PreCreateEvent::class,
+        StageEventException::class,
+      ],
+      'pre-require validation error' => [
+        PreRequireEvent::class,
+        StageEventException::class,
+      ],
+      'pre-apply validation error' => [
+        PreApplyEvent::class,
+        StageEventException::class,
+      ],
+      'pre-destroy validation error' => [
+        PreDestroyEvent::class,
+        StageEventException::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the stage is destroyed if an error occurs during a cron update.
+   *
+   * @param string $event_class
+   *   The stage life cycle event which should raise an error.
+   * @param string $exception_class
+   *   The class of exception that will be thrown when the given event is fired.
+   *
+   * @dataProvider providerStageDestroyedOnError
+   */
+  public function testStageDestroyedOnError(string $event_class, string $exception_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    }
+    $this->installConfig('automatic_updates');
+    // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
+    $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",
+    ]);
+
+    // If the pre- or post-destroy events throw an exception, it will not be
+    // caught by the cron update stage, but it *will* be caught by the main cron
+    // service, which will log it as a cron error that we'll want to check for.
+    $cron_logger = new TestLogger();
+    $this->container->get('logger.factory')
+      ->get('cron')
+      ->addLogger($cron_logger);
+
+    /** @var \Drupal\automatic_updates\CronUpdateStage $stage */
+    $stage = $this->container->get(CronUpdateStage::class);
+
+    // When the event specified by $event_class is dispatched, either throw an
+    // exception directly from the event subscriber, or prepare a
+    // StageEventException which will format the validation errors its own way.
+    if ($exception_class === StageEventException::class) {
+      $error = ValidationResult::createError([
+        t('Destroy the stage!'),
+      ]);
+      $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $stage);
+      TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
+    }
+    else {
+      /** @var \Throwable $exception */
+      $exception = new $exception_class('Destroy the stage!');
+      TestSubscriber1::setException($exception, $event_class);
+    }
+    $expected_log_message = $exception->getMessage();
+
+    // Ensure that nothing has been logged yet.
+    $this->assertEmpty($cron_logger->records);
+    $this->assertEmpty($this->logger->records);
+
+    $this->assertTrue($stage->isAvailable());
+    $this->container->get('cron')->run();
+
+    $logged_by_stage = $this->logger->hasRecord($expected_log_message, (string) RfcLogLevel::ERROR);
+    // To check if the exception was logged by the main cron service, we need
+    // to set up a special predicate, because exceptions logged by cron are
+    // always formatted in a particular way that we don't control. But the
+    // original exception object is stored with the log entry, so we look for
+    // that and confirm that its message is the same.
+    // @see watchdog_exception()
+    $predicate = function (array $record) use ($exception): bool {
+      if (isset($record['context']['exception'])) {
+        return $record['context']['exception']->getMessage() === $exception->getMessage();
+      }
+      return FALSE;
+    };
+    $logged_by_cron = $cron_logger->hasRecordThatPasses($predicate, (string) RfcLogLevel::ERROR);
+
+    // If a pre-destroy event flags a validation error, it's handled like any
+    // other event (logged by the cron update stage, but not the main cron
+    // service). But if a pre- or post-destroy event throws an exception, the
+    // cron update stage won't try to catch it. Instead, it will be caught and
+    // logged by the main cron service.
+    if ($event_class === PreDestroyEvent::class || $event_class === PostDestroyEvent::class) {
+      // If the pre-destroy event throws an exception or flags a validation
+      // error, the stage won't be destroyed. But, once the post-destroy event
+      // is fired, the stage should be fully destroyed and marked as available.
+      $this->assertSame($event_class === PostDestroyEvent::class, $stage->isAvailable());
+    }
+    else {
+      $this->assertTrue($stage->isAvailable());
+    }
+    $this->assertTrue($logged_by_stage);
+    $this->assertFalse($logged_by_cron);
+  }
+
+  /**
+   * Tests stage is destroyed if not available and site is on insecure version.
+   */
+  public function testStageDestroyedIfNotAvailable(): void {
+    $stage = $this->createStage();
+    $stage_id = $stage->create();
+    $original_stage_directory = $stage->getStageDirectory();
+    $this->assertDirectoryExists($original_stage_directory);
+
+    $listener = function (PostRequireEvent $event) use (&$cron_stage_dir, $original_stage_directory): void {
+      $this->assertDirectoryDoesNotExist($original_stage_directory);
+      $cron_stage_dir = $this->container->get('package_manager.stager')->getInvocationArguments()[0][1]->resolve();
+      $this->assertSame($event->stage->getStageDirectory(), $cron_stage_dir);
+      $this->assertDirectoryExists($cron_stage_dir);
+    };
+    $this->addEventTestListener($listener, PostRequireEvent::class);
+
+    $this->container->get('cron')->run();
+    $this->assertIsString($cron_stage_dir);
+    $this->assertNotEquals($original_stage_directory, $cron_stage_dir);
+    $this->assertDirectoryDoesNotExist($cron_stage_dir);
+    $this->assertTrue($this->logger->hasRecord('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.', (string) RfcLogLevel::NOTICE));
+
+    $stage2 = $this->createStage();
+    $stage2->create();
+    $this->expectException(StageOwnershipException::class);
+    $this->expectExceptionMessage('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.');
+    $stage->claim($stage_id);
+  }
+
+  /**
+   * Tests stage is not destroyed if another update is applying.
+   */
+  public function testStageNotDestroyedIfApplying(): void {
+    $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",
+    ]);
+    $this->setCoreVersion('9.8.0');
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/core:9.8.1']);
+    $stop_error = t('Stopping stage from applying');
+
+    // Add a PreApplyEvent event listener so we can attempt to run cron when
+    // another stage is applying.
+    $this->addEventTestListener(function (PreApplyEvent $event) use ($stop_error) {
+      // Ensure the stage that is applying the operation is not the cron
+      // update stage.
+      $this->assertInstanceOf(TestStage::class, $event->stage);
+      $this->container->get('cron')->run();
+      // We do not actually want to apply this operation it was just invoked to
+      // allow cron to be  attempted.
+      $event->addError([$stop_error]);
+    });
+
+    try {
+      $stage->apply();
+      $this->fail('Expected update to fail');
+    }
+    catch (StageEventException $exception) {
+      $this->assertExpectedResultsFromException([ValidationResult::createError([$stop_error])], $exception);
+    }
+
+    $this->assertTrue($this->logger->hasRecord("Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href=\"%url\">update form</a>.", (string) RfcLogLevel::NOTICE));
+    $this->assertUpdateStagedTimes(1);
+  }
+
+  /**
+   * Tests stage is not destroyed if not available and site is on secure version.
+   */
+  public function testStageNotDestroyedIfSecure(): void {
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
+    $this->mockActiveCoreVersion('9.8.1');
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/random']);
+    $this->assertUpdateStagedTimes(1);
+
+    // Trigger CronUpdateStage, the above should cause it to detect a stage that
+    // is applying.
+    $this->container->get('cron')->run();
+
+    $this->assertTrue($this->logger->hasRecord('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.', (string) RfcLogLevel::NOTICE));
+    $this->assertUpdateStagedTimes(1);
+  }
+
+  /**
+   * Tests that CronUpdateStage::begin() unconditionally throws an exception.
+   */
+  public function testBeginThrowsException(): void {
+    $this->expectExceptionMessage(CronUpdateStage::class . '::begin() cannot be called directly.');
+    $this->container->get(CronUpdateStage::class)
+      ->begin(['drupal' => '9.8.0']);
+  }
+
+  /**
+   * Tests that email is sent when an unattended update succeeds.
+   */
+  public function testEmailOnSuccess(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2');
+    $this->drush('auto-update');
+    $this->drupalGet('admin/reports/dblog');
+    file_put_contents("/Users/ted.bowman/sites/test.html", $this->getSession()->getPage()->getOuterHtml());
+
+    // Ensure we sent a success message to all recipients.
+    $expected_body = <<<END
+Congratulations!
+
+Drupal core was automatically updated from 9.8.0 to 9.8.2.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("Drupal core was successfully updated", $expected_body);
+    $this->assertRegularCronRun(FALSE);
+  }
+
+  /**
+   * Tests that regular cron runs if not update is available.
+   */
+  public function testNoUpdateAvailable(): void {
+    $this->setCoreVersion('9.8.2');
+    $this->container->get('cron')->run();
+    $this->assertRegularCronRun(TRUE);
+  }
+
+  /**
+   * Tests that regular cron does not run if an update is started.
+   *
+   * @param string $event_exception_class
+   *   The event in which to throw the exception.
+   *
+   * @dataProvider providerRegularCronRuns
+   */
+  public function testRegularCronRuns(string $event_exception_class): void {
+    $this->addEventTestListener(
+      function (): void {
+        throw new \Exception('😜');
+      },
+      $event_exception_class
+    );
+    try {
+      $this->container->get('cron')->run();
+    }
+    catch (\Exception $e) {
+      if ($event_exception_class !== PostDestroyEvent::class && $event_exception_class !== PreDestroyEvent::class) {
+        // No other events should result in an exception.
+        throw $e;
+      }
+      $this->assertSame('😜', $e->getMessage());
+    }
+    $this->assertRegularCronRun($event_exception_class === PreCreateEvent::class);
+  }
+
+  /**
+   * Data provider for testStageDestroyedOnError().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerRegularCronRuns(): array {
+    return [
+      'pre-create exception' => [PreCreateEvent::class],
+      'post-create exception' => [PostCreateEvent::class],
+      'pre-require exception' => [PreRequireEvent::class],
+      'post-require exception' => [PostRequireEvent::class],
+      'pre-apply exception' => [PreApplyEvent::class],
+      'post-apply exception' => [PostApplyEvent::class],
+      'pre-destroy exception' => [PreDestroyEvent::class],
+      'post-destroy exception' => [PostDestroyEvent::class],
+    ];
+  }
+
+  /**
+   * Data provider for ::testEmailOnFailure().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerEmailOnFailure(): array {
+    return [
+      'pre-create' => [
+        PreCreateEvent::class,
+      ],
+      'pre-require' => [
+        PreRequireEvent::class,
+      ],
+      'pre-apply' => [
+        PreApplyEvent::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended non-security update fails.
+   *
+   * @param string $event_class
+   *   The event class that should trigger the failure.
+   *
+   * @dataProvider providerEmailOnFailure
+   */
+  public function testNonUrgentFailureEmail(string $event_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2');
+    }
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml',
+    ]);
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
+
+    $error = ValidationResult::createError([
+      t('Error while updating!'),
+    ]);
+    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(CronUpdateStage::class));
+    TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
+
+    $this->container->get('cron')->run();
+
+    $url = Url::fromRoute('update.report_update')
+      ->setAbsolute()
+      ->toString();
+
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.2. The following error was logged:
+
+{$exception->getMessage()}
+
+No immediate action is needed, but it is recommended that you visit $url to perform the update, or at least check that everything still looks good.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("Drupal core update failed", $expected_body);
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended security update fails.
+   *
+   * @param string $event_class
+   *   The event class that should trigger the failure.
+   *
+   * @dataProvider providerEmailOnFailure
+   */
+  public function testSecurityUpdateFailureEmail(string $event_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    }
+
+    $error = ValidationResult::createError([
+      t('Error while updating!'),
+    ]);
+    TestSubscriber1::setTestResult([$error], $event_class);
+    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(CronUpdateStage::class));
+
+    $this->container->get('cron')->run();
+
+    $url = Url::fromRoute('update.report_update')
+      ->setAbsolute()
+      ->toString();
+
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
+
+{$exception->getMessage()}
+
+Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit $url to perform the update.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("URGENT: Drupal core update failed", $expected_body);
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended update fails to apply.
+   */
+  public function testApplyFailureEmail(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    $error = new \LogicException('I drink your milkshake!');
+    LoggingCommitter::setException($error);
+
+    $this->container->get('cron')->run();
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
+
+Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. Caused by LogicException, with this message: {$error->getMessage()}
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent('URGENT: Drupal core update failed', $expected_body);
+  }
+
+  /**
+   * Tests that setLogger is called on the cron update stage service.
+   */
+  public function testLoggerIsSetByContainer(): void {
+    $stage_method_calls = $this->container->getDefinition('automatic_updates.cron_update_stage')->getMethodCalls();
+    $this->assertSame('setLogger', $stage_method_calls[0][0]);
+  }
+
+  private function assertRegularCronRun(bool $expected_cron_run) {
+    $this->assertSame($expected_cron_run, $this->container->get('state')->get('common_test.cron') === 'success');
+  }
+
+}
-- 
GitLab