diff --git a/automatic_updates.module b/automatic_updates.module index d3fe134f8dc414d6f7b4d11715230c13c1b87492..e8a71bd01d3f5015797358512a1fb079f387a317 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -47,24 +47,45 @@ function automatic_updates_mail(string $key, array &$message, array $params): vo $options = [ 'langcode' => $message['langcode'], ]; - switch ($key) { - case 'cron_successful': - $message['subject'] = t("Drupal core was successfully updated", [], $options); - $message['body'][] = t('Congratulations!', [], $options); - $message['body'][] = t('Drupal core was automatically updated from @previous_version to @updated_version.', [ - '@previous_version' => $params['previous_version'], - '@updated_version' => $params['updated_version'], - ], $options); - break; + if ($key === 'cron_successful') { + $message['subject'] = t("Drupal core was successfully updated", [], $options); + $message['body'][] = t('Congratulations!', [], $options); + $message['body'][] = t('Drupal core was automatically updated from @previous_version to @updated_version.', [ + '@previous_version' => $params['previous_version'], + '@updated_version' => $params['updated_version'], + ], $options); + } + elseif (str_starts_with($key, 'cron_failed')) { + $message['subject'] = t("Drupal core update failed", [], $options); - case 'cron_failed': - $message['subject'] = t("Drupal core update failed", [], $options); - $message['body'][] = t('Drupal core failed to update automatically from @previous_version to @target_version. The following error was logged:', [ - '@previous_version' => $params['previous_version'], - '@target_version' => $params['target_version'], + // If this is considered urgent, prefix the subject line with a call to + // action. + if ($params['urgent']) { + $message['subject'] = t('URGENT: @subject', [ + '@subject' => $message['subject'], ], $options); - $message['body'][] = $params['error_message']; - break; + } + + $message['body'][] = t('Drupal core failed to update automatically from @previous_version to @target_version. The following error was logged:', [ + '@previous_version' => $params['previous_version'], + '@target_version' => $params['target_version'], + ], $options); + $message['body'][] = $params['error_message']; + + // If the problem was not due to a failed apply, provide a link for the site + // owner to do the update. + if ($key !== 'cron_failed_apply') { + $url = Url::fromRoute('update.report_update') + ->setAbsolute() + ->toString(); + + if ($key === 'cron_failed_insecure') { + $message['body'][] = t('Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit @url to perform the update.', ['@url' => $url], $options); + } + else { + $message['body'][] = t('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.', ['@url' => $url], $options); + } + } } // If this email was related to an unattended update, explicitly state that diff --git a/package_manager/src/ProjectInfo.php b/package_manager/src/ProjectInfo.php index fd1343a5596f66e2857927fc9da8546d6374f4e3..ef3b78301b6149988f8bf84386605381d690fc32 100644 --- a/package_manager/src/ProjectInfo.php +++ b/package_manager/src/ProjectInfo.php @@ -185,4 +185,26 @@ final class ProjectInfo { return $available_projects; } + /** + * Checks if the installed version of this project is safe to use. + * + * @return bool + * TRUE if the installed version of this project is secure, supported, and + * published. Otherwise, or if the project information could not be + * retrieved, returns FALSE. + */ + public function isInstalledVersionSafe(): bool { + $project_data = $this->getProjectInfo(); + if ($project_data) { + $unsafe = [ + UpdateManagerInterface::NOT_SECURE, + UpdateManagerInterface::NOT_SUPPORTED, + UpdateManagerInterface::REVOKED, + ]; + return !in_array($project_data['status'], $unsafe, TRUE); + } + // If we couldn't get project data, assume the installed version is unsafe. + return FALSE; + } + } diff --git a/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml b/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml index cfe1402cd12920013307f9e28376ce2a26e7c9c4..581893092f4c0215e40a2b01941e1bce5df558d4 100644 --- a/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml +++ b/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml @@ -3,7 +3,7 @@ Contains metadata about the following (fake) releases of Drupal core, in order: * 9.8.2 * 9.8.1, which is unsupported -* 9.8.0 +* 9.8.0, which is unpublished * 9.8.0-alpha1 * 9.7.1 * 9.7.0 diff --git a/package_manager/tests/src/Kernel/ProjectInfoTest.php b/package_manager/tests/src/Kernel/ProjectInfoTest.php index 70952df90a40c088b66c271d26505c1f3d6ca342..2a459cc8bb5bbbf82a04d24b3a1e3c5b2bbbdb07 100644 --- a/package_manager/tests/src/Kernel/ProjectInfoTest.php +++ b/package_manager/tests/src/Kernel/ProjectInfoTest.php @@ -176,4 +176,65 @@ class ProjectInfoTest extends PackageManagerKernelTestBase { $project_info->getInstallableReleases(); } + /** + * Data provider for ::testInstalledVersionSafe(). + * + * @return array[] + * The test cases. + */ + public function providerInstalledVersionSafe(): array { + $dir = __DIR__ . '/../../fixtures/release-history'; + + return [ + 'safe version' => [ + '9.8.0', + $dir . '/drupal.9.8.2.xml', + TRUE, + ], + 'unpublished version' => [ + '9.8.0', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported branch' => [ + '9.6.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported version' => [ + '9.8.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'insecure version' => [ + '9.8.0', + $dir . '/drupal.9.8.1-security.xml', + FALSE, + ], + ]; + } + + /** + * Tests checking if the currently installed version of a project is safe. + * + * @param string $installed_version + * The currently installed version of the project. + * @param string $release_xml + * The path of the release metadata. + * @param bool $expected_to_be_safe + * Whether or not the installed version of the project is expected to be + * found safe. + * + * @covers ::isInstalledVersionSafe + * + * @dataProvider providerInstalledVersionSafe + */ + public function testInstalledVersionSafe(string $installed_version, string $release_xml, bool $expected_to_be_safe): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_xml]); + + $project_info = new ProjectInfo('drupal'); + $this->assertSame($expected_to_be_safe, $project_info->isInstalledVersionSafe()); + } + } diff --git a/src/CronUpdater.php b/src/CronUpdater.php index dc8711275f7997b5302be4acec08ca5e3dfa287a..5239aa9161083bf08d01736339f10b312d073e71 100644 --- a/src/CronUpdater.php +++ b/src/CronUpdater.php @@ -7,6 +7,7 @@ use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Mail\MailManagerInterface; use Drupal\Core\State\StateInterface; use Drupal\Core\Url; +use Drupal\package_manager\Exception\ApplyFailedException; use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\ProjectInfo; use Drupal\update\ProjectRelease; @@ -160,13 +161,15 @@ class CronUpdater extends Updater { * Performs the update. * * @param string $target_version - * The target version. + * The target version of Drupal core. * @param int|null $timeout * How long to allow the operation to run before timing out, in seconds, or * NULL to never time out. */ private function performUpdate(string $target_version, ?int $timeout): void { - $installed_version = (new ProjectInfo('drupal'))->getInstalledVersion(); + $project_info = new ProjectInfo('drupal'); + + $installed_version = $project_info->getInstalledVersion(); if (empty($installed_version)) { $this->logger->error('Unable to determine the current version of Drupal core.'); return; @@ -188,8 +191,21 @@ class CronUpdater extends Updater { 'target_version' => $target_version, 'error_message' => $e->getMessage(), ]; + if ($e instanceof ApplyFailedException || $e->getPrevious() instanceof ApplyFailedException) { + $mail_params['urgent'] = TRUE; + $key = 'cron_failed_apply'; + } + elseif (!$project_info->isInstalledVersionSafe()) { + $mail_params['urgent'] = TRUE; + $key = 'cron_failed_insecure'; + } + else { + $mail_params['urgent'] = FALSE; + $key = 'cron_failed'; + } + foreach ($this->getEmailRecipients() as $email => $langcode) { - $this->mailManager->mail('automatic_updates', 'cron_failed', $email, $langcode, $mail_params); + $this->mailManager->mail('automatic_updates', $key, $email, $langcode, $mail_params); } $this->logger->error($e->getMessage()); diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php index 806e4c0c5896bd6765592f2fcaf5908aec786d3a..c0d0a1639132fe6334ace6968b366cee86dfbc8e 100644 --- a/tests/src/Kernel/CronUpdaterTest.php +++ b/tests/src/Kernel/CronUpdaterTest.php @@ -8,6 +8,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Form\FormState; use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Test\AssertMailTrait; +use Drupal\Core\Url; use Drupal\package_manager\Event\PostApplyEvent; use Drupal\package_manager\Event\PostCreateEvent; use Drupal\package_manager\Event\PostDestroyEvent; @@ -18,6 +19,7 @@ use Drupal\package_manager\Event\PreRequireEvent; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_bypass\Committer; use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\update\UpdateSettingsForm; @@ -59,6 +61,8 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { * should be emailed in. * * @var string[] + * + * @see ::setUp() */ private $emailRecipients = []; @@ -389,17 +393,16 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { $this->container->get('cron')->run(); // Ensure we sent a success message to all recipients. - $sent_messages = $this->getMails([ - 'subject' => "Drupal core was successfully updated", - ]); - $this->assertNotEmpty($sent_messages); - $this->assertSame(count($this->emailRecipients), count($sent_messages)); + $expected_body = <<<END +Congratulations! - foreach ($sent_messages as $message) { - $email = $message['to']; - $this->assertSame($message['langcode'], $this->emailRecipients[$email]); - $this->assertCorrectMessageSent($email, $message, $message['langcode'], "Congratulations!\n\nDrupal core was automatically updated from 9.8.0 to 9.8.1.\n"); - } +Drupal core was automatically updated from 9.8.0 to 9.8.1. + +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); } /** @@ -423,61 +426,140 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { } /** - * Tests that email is sent when an unattended update fails. + * 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 testEmailOnFailure(string $event_class): void { + public function testNonUrgentFailureEmail(string $event_class): void { + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + $this->config('automatic_updates.settings') + ->set('cron', CronUpdater::ALL) + ->save(); + $results = [ ValidationResult::createError(['Error while updating!']), ]; TestSubscriber1::setTestResult($results, $event_class); $exception = new StageValidationException($results); + $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 { + $results = [ + ValidationResult::createError(['Error while updating!']), + ]; + TestSubscriber1::setTestResult($results, $event_class); + $exception = new StageValidationException($results); + $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 { + $error = new \Exception('I drink your milkshake!'); + Committer::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: - // Ensure we sent a failure message to all recipients. +The update operation failed to apply. The update may have been partially applied. It is recommended that the site be restored from a code backup. + +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); + } + + /** + * Asserts that all recipients recieved a given email. + * + * @param string $subject + * The subject line of the email that should have been sent. + * @param string $body + * The beginning of the body text of the email that should have been sent. + * + * @see ::$emailRecipients + */ + private function assertMessagesSent(string $subject, string $body): void { $sent_messages = $this->getMails([ - 'subject' => "Drupal core update failed", + 'subject' => $subject, ]); $this->assertNotEmpty($sent_messages); $this->assertSame(count($this->emailRecipients), count($sent_messages)); + // Ensure the body is formatted the way the PHP mailer would do it. + $message = [ + 'body' => [$body], + ]; + $message = $this->container->get('plugin.manager.mail') + ->createInstance('php_mail') + ->format($message); + $body = $message['body']; + foreach ($sent_messages as $message) { $email = $message['to']; - $this->assertSame($message['langcode'], $this->emailRecipients[$email]); - $this->assertCorrectMessageSent($email, $message, $message['langcode'], "Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following\nerror was logged:\n\n" . $exception->getMessage()); + $expected_langcode = $this->emailRecipients[$email]; + + $this->assertSame($expected_langcode, $message['langcode']); + // The message, and every line in it, should have been sent in the + // expected language. + // @see automatic_updates_test_mail_alter() + $this->assertArrayHasKey('line_langcodes', $message); + $this->assertSame([$expected_langcode], $message['line_langcodes']); + $this->assertStringStartsWith($body, $message['body']); } } - /** - * Asserts correct message sent to correct recipient. - * - * @param string $expected_recipient - * The email address that should have received the message. - * @param array $sent_message - * The sent message, as processed by hook_mail(). - * @param string $expected_language_code - * The language code that the recipient should have been emailed in. - * @param string $expected_body_text - * The expected message that the email body should contain. - */ - private function assertCorrectMessageSent(string $expected_recipient, array $sent_message, string $expected_language_code, string $expected_body_text): void { - // Ensure the messages had the correct body text, and were sent to the right - // people. - $this->assertSame($sent_message['to'], $expected_recipient); - $this->assertStringStartsWith($expected_body_text, $sent_message['body']); - // The message, and every line in it, should have been sent in the - // expected language. - $this->assertSame($expected_language_code, $sent_message['langcode']); - // @see automatic_updates_test_mail_alter() - $this->assertArrayHasKey('line_langcodes', $sent_message); - $this->assertSame([$expected_language_code], $sent_message['line_langcodes']); - } - /** * Tests that setLogger is called on the cron updater service. */