Skip to content
Snippets Groups Projects
Commit 0d680aae authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3316611 by phenaproxima, tedbow: If unattended updates are enabled,...

Issue #3316611 by phenaproxima, tedbow: If unattended updates are enabled, send an email when status checks start failing
parent cb11dd23
Branches
Tags
1 merge request!554Issue #3316611: If unattended updates are enabled send an email when the site stops passing readiness checks
Showing
with 680 additions and 130 deletions
......@@ -6,6 +6,7 @@
*/
use Drupal\automatic_updates\BatchProcessor;
use Drupal\automatic_updates\CronUpdater;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\automatic_updates\Validation\AdminStatusCheckMessages;
use Drupal\Core\Url;
......@@ -95,6 +96,16 @@ function automatic_updates_mail(string $key, array &$message, array $params): vo
}
}
}
elseif ($key === 'status_check_failed') {
$message['subject'] = t('Automatic updates readiness checks failed', [], $options);
$url = Url::fromRoute('system.status')
->setAbsolute()
->toString();
$message['body'][] = t('Your site has failed some readiness checks for automatic updates and may not be able to receive automatic updates until further action is taken. Please visit @url for more information.', [
'@url' => $url,
], $options);
}
// If this email was related to an unattended update, explicitly state that
// this isn't supported yet.
......@@ -153,7 +164,9 @@ function automatic_updates_module_implements_alter(&$implementations, $hook) {
* Implements hook_cron().
*/
function automatic_updates_cron() {
\Drupal::service('automatic_updates.cron_updater')->handleCron();
/** @var \Drupal\automatic_updates\CronUpdater $updater */
$updater = \Drupal::service('automatic_updates.cron_updater');
$updater->handleCron();
/** @var \Drupal\automatic_updates\Validation\StatusChecker $status_checker */
$status_checker = \Drupal::service('automatic_updates.status_checker');
......@@ -165,6 +178,11 @@ function automatic_updates_cron() {
$status_checker->run();
}
// Only try to send failure notifications if unattended updates are enabled.
if ($updater->getMode() !== CronUpdater::DISABLED) {
\Drupal::service('automatic_updates.status_check_mailer')
->sendFailureNotifications($last_results, $status_checker->getResults());
}
}
/**
......
<?php
/**
* @file
* Contains post-update hooks for Automatic Updates.
*/
use Drupal\automatic_updates\StatusCheckMailer;
/**
* Creates the automatic_updates.settings:status_check_mail config.
*/
function automatic_updates_post_update_create_status_check_mail_config(): void {
\Drupal::configFactory()
->getEditable('automatic_updates.settings')
->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)
->save();
}
......@@ -14,6 +14,12 @@ services:
- 24
tags:
- { name: event_subscriber }
automatic_updates.status_check_mailer:
class: Drupal\automatic_updates\StatusCheckMailer
arguments:
- '@config.factory'
- '@plugin.manager.mail'
- '@language_manager'
automatic_updates.readiness_validation_manager:
class: Drupal\automatic_updates\Validation\ReadinessValidationManager
arguments:
......@@ -41,7 +47,7 @@ services:
- '@automatic_updates.release_chooser'
- '@logger.factory'
- '@plugin.manager.mail'
- '@language_manager'
- '@automatic_updates.status_check_mailer'
- '@state'
- '@config.factory'
- '@package_manager.path_locator'
......
cron: security
allow_core_minor_updates: false
status_check_mail: errors_only
......@@ -11,3 +11,6 @@ automatic_updates.settings:
allow_core_minor_updates:
type: boolean
label: 'Allow minor level Drupal core updates'
status_check_mail:
type: string
label: 'Whether to send status check failure e-mail notifications during cron'
......@@ -133,4 +133,24 @@ final class ValidationResult {
return $results ? SystemManager::REQUIREMENT_WARNING : SystemManager::REQUIREMENT_OK;
}
/**
* Determines if two validation results are equivalent.
*
* @param self $a
* A validation result.
* @param self $b
* Another validation result.
*
* @return bool
* TRUE if the given validation results have the same severity, summary,
* and messages (in the same order); otherwise FALSE.
*/
public static function isEqual(self $a, self $b): bool {
return (
$a->getSeverity() === $b->getSeverity() &&
strval($a->getSummary()) === strval($b->getSummary()) &&
array_map('strval', $a->getMessages()) === array_map('strval', $b->getMessages())
);
}
}
......@@ -2,6 +2,8 @@
namespace Drupal\Tests\package_manager\Traits;
use Drupal\package_manager\ValidationResult;
/**
* Contains helpful methods for testing stage validators.
*/
......@@ -20,12 +22,7 @@ trait ValidationTestTrait {
foreach ($expected_results as $expected_result) {
$actual_result = array_shift($actual_results);
$this->assertSame($expected_result->getSeverity(), $actual_result->getSeverity());
$this->assertSame((string) $expected_result->getSummary(), (string) $actual_result->getSummary());
$this->assertSame(
array_map('strval', $expected_result->getMessages()),
array_map('strval', $actual_result->getMessages())
);
$this->assertTrue(ValidationResult::isEqual($expected_result, $actual_result));
}
}
......
......@@ -2,7 +2,6 @@
namespace Drupal\automatic_updates;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\State\StateInterface;
......@@ -19,7 +18,9 @@ use Symfony\Component\HttpFoundation\Response;
*
* @internal
* This class implements logic specific to Automatic Updates' cron hook
* implementation. It should not be called directly.
* implementation and may be changed or removed at any time without warning.
* It should not be called directly, and external code should not interact
* with it.
*/
class CronUpdater extends Updater {
......@@ -75,11 +76,11 @@ class CronUpdater extends Updater {
protected $mailManager;
/**
* The language manager service.
* The status check mailer service.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
* @var \Drupal\automatic_updates\StatusCheckMailer
*/
protected $languageManager;
protected $statusCheckMailer;
/**
* The state service.
......@@ -97,19 +98,19 @@ class CronUpdater extends Updater {
* The logger channel factory.
* @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
* The mail manager service.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager service.
* @param \Drupal\automatic_updates\StatusCheckMailer $status_check_mailer
* The status check mailer service.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param mixed ...$arguments
* Additional arguments to pass to the parent constructor.
*/
public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, StateInterface $state, ...$arguments) {
public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, MailManagerInterface $mail_manager, StatusCheckMailer $status_check_mailer, StateInterface $state, ...$arguments) {
parent::__construct(...$arguments);
$this->releaseChooser = $release_chooser;
$this->logger = $logger_factory->get('automatic_updates');
$this->mailManager = $mail_manager;
$this->languageManager = $language_manager;
$this->statusCheckMailer = $status_check_mailer;
$this->state = $state;
}
......@@ -204,7 +205,7 @@ class CronUpdater extends Updater {
$key = 'cron_failed';
}
foreach ($this->getEmailRecipients() as $email => $langcode) {
foreach ($this->statusCheckMailer->getRecipients() as $email => $langcode) {
$this->mailManager->mail('automatic_updates', $key, $email, $langcode, $mail_params);
}
$this->logger->error($e->getMessage());
......@@ -306,7 +307,7 @@ class CronUpdater extends Updater {
'previous_version' => $installed_version,
'updated_version' => $target_version,
];
foreach ($this->getEmailRecipients() as $recipient => $langcode) {
foreach ($this->statusCheckMailer->getRecipients() as $recipient => $langcode) {
$this->mailManager->mail('automatic_updates', 'cron_successful', $recipient, $langcode, $mail_params);
}
}
......@@ -328,40 +329,6 @@ class CronUpdater extends Updater {
return new Response();
}
/**
* Retrieves preferred language to send email.
*
* @param string $recipient
* The email address of the recipient.
*
* @return string
* The preferred language of the recipient.
*/
protected function getEmailLangcode(string $recipient): string {
$user = user_load_by_mail($recipient);
if ($user) {
return $user->getPreferredLangcode();
}
return $this->languageManager->getDefaultLanguage()->getId();
}
/**
* Returns an array of people to email with success or failure notifications.
*
* @return string[]
* An array whose keys are the email addresses to send notifications to, and
* values are the langcodes that they should be emailed in.
*/
protected function getEmailRecipients(): array {
$recipients = $this->configFactory->get('update.settings')
->get('notification.emails');
$emails = [];
foreach ($recipients as $recipient) {
$emails[$recipient] = $this->getEmailLangcode($recipient);
}
return $emails;
}
/**
* Gets the cron update mode.
*
......
......@@ -2,6 +2,8 @@
namespace Drupal\automatic_updates\EventSubscriber;
use Drupal\automatic_updates\CronUpdater;
use Drupal\automatic_updates\StatusCheckMailer;
use Drupal\automatic_updates\Validation\StatusChecker;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
......@@ -53,9 +55,25 @@ final class ConfigSubscriber implements EventSubscriberInterface {
* The event object.
*/
public function onConfigSave(ConfigCrudEvent $event): void {
if ($event->getConfig()->getName() === 'package_manager.settings' && $event->isChanged('executables.composer')) {
$config = $event->getConfig();
// If the path of the Composer executable has changed, the status check
// results are likely to change as well.
if ($config->getName() === 'package_manager.settings' && $event->isChanged('executables.composer')) {
$this->statusChecker->clearStoredResults();
}
elseif ($config->getName() === 'automatic_updates.settings') {
// 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') === CronUpdater::DISABLED) {
$this->statusChecker->clearStoredResults();
}
elseif ($event->isChanged('status_check_mail') && $config->get('status_check_mail') !== StatusCheckMailer::DISABLED) {
$this->statusChecker->clearStoredResults();
}
}
}
}
<?php
namespace Drupal\automatic_updates;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\package_manager\ValidationResult;
use Drupal\system\SystemManager;
/**
* Defines a service to send status check failure e-mails during cron.
*
* @internal
* This is an internal part of Automatic Updates and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class StatusCheckMailer {
/**
* Never send failure notifications.
*
* @var string
*/
public const DISABLED = 'disabled';
/**
* Send failure notifications if status checks raise any errors or warnings.
*
* @var string
*/
public const ALL = 'all';
/**
* Only send failure notifications if status checks raise errors.
*
* @var string
*/
public const ERRORS_ONLY = 'errors_only';
/**
* The config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $configFactory;
/**
* The mail manager service.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected MailManagerInterface $mailManager;
/**
* The language manager service.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected LanguageManagerInterface $languageManager;
/**
* Constructs a StatusCheckNotifier object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
* The mail manager service.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager service.
*/
public function __construct(ConfigFactoryInterface $config_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager) {
$this->configFactory = $config_factory;
$this->mailManager = $mail_manager;
$this->languageManager = $language_manager;
}
/**
* Sends status check failure notifications if necessary.
*
* Notifications will only be sent if the following conditions are fulfilled:
* - Notifications are enabled.
* - If we are configured to only send notifications if there are errors, the
* current result set must contain at least one error result.
* - The previous and current result sets, after filtering, are different.
*
* @param \Drupal\package_manager\ValidationResult[]|null $previous_results
* The previous set of status check results, if any.
* @param \Drupal\package_manager\ValidationResult[] $current_results
* The current set of status check results.
*/
public function sendFailureNotifications(?array $previous_results, array $current_results): void {
$level = $this->configFactory->get('automatic_updates.settings')
->get('status_check_mail');
if ($level === static::DISABLED) {
return;
}
// If we're ignoring warnings, filter them out of the previous and current
// result sets.
elseif ($level === static::ERRORS_ONLY) {
$filter = function (ValidationResult $result): bool {
return $result->getSeverity() === SystemManager::REQUIREMENT_ERROR;
};
$current_results = array_filter($current_results, $filter);
// If the current results don't have any errors, there's nothing else
// for us to do.
if (empty($current_results)) {
return;
}
if ($previous_results) {
$previous_results = array_filter($previous_results, $filter);
}
}
if ($this->resultsAreDifferent($previous_results, $current_results)) {
foreach ($this->getRecipients() as $email => $langcode) {
$this->mailManager->mail('automatic_updates', 'status_check_failed', $email, $langcode, []);
}
}
}
/**
* Determines if two sets of validation results are different.
*
* @param \Drupal\package_manager\ValidationResult[]|null $previous_results
* The previous set of validation results, if any.
* @param \Drupal\package_manager\ValidationResult[] $current_results
* The current set of validation results.
*
* @return bool
* TRUE if the given result sets are different; FALSE otherwise.
*/
protected function resultsAreDifferent(?array $previous_results, array $current_results): bool {
if ($previous_results === NULL || count($previous_results) !== count($current_results)) {
return TRUE;
}
// We can't rely on the previous and current result sets being in the same
// order, so we need to use this inefficient nested loop to check if each
// previous result is anywhere in the current result set. This is a case
// where accuracy is probably more important than performance.
$result_previously_existed = function (ValidationResult $result) use ($previous_results): bool {
foreach ($previous_results as $previous_result) {
if (ValidationResult::isEqual($result, $previous_result)) {
return TRUE;
}
}
return FALSE;
};
foreach ($current_results as $result) {
if (!$result_previously_existed($result)) {
return TRUE;
}
}
return FALSE;
}
/**
* Returns an array of people to email.
*
* @return string[]
* An array whose keys are the email addresses to send notifications to, and
* values are the langcodes that they should be emailed in.
*/
public function getRecipients(): array {
$recipients = $this->configFactory->get('update.settings')
->get('notification.emails');
$emails = [];
foreach ($recipients as $recipient) {
$emails[$recipient] = $this->getEmailLangcode($recipient);
}
return $emails;
}
/**
* Retrieves preferred language to send email.
*
* @param string $recipient
* The email address of the recipient.
*
* @return string
* The preferred language of the recipient.
*/
protected function getEmailLangcode(string $recipient): string {
$user = user_load_by_mail($recipient);
if ($user) {
return $user->getPreferredLangcode();
}
return $this->languageManager->getDefaultLanguage()->getId();
}
}
......@@ -3,6 +3,7 @@
namespace Drupal\Tests\automatic_updates\Functional;
use Behat\Mink\Element\NodeElement;
use Drupal\automatic_updates\StatusCheckMailer;
use Drupal\automatic_updates_test\Datetime\TestTime;
use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
use Drupal\automatic_updates_test2\EventSubscriber\TestSubscriber2;
......@@ -444,6 +445,58 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
$assert_session->pageTextNotContains($message);
}
/**
* Tests that stored results are deleted after certain config changes.
*/
public function testStoredResultsClearedAfterConfigChanges(): void {
$this->drupalLogin($this->checkerRunnerUser);
// Flag a validation error, which will be displayed in the messages area.
$result = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
TestSubscriber1::setTestResult([$result], StatusCheckEvent::class);
$message = $result->getMessages()[0];
$this->container->get('module_installer')->install([
'automatic_updates',
'automatic_updates_test',
]);
$this->container = $this->container->get('kernel')->getContainer();
// The error should be persistently visible, even after the checker stops
// flagging it.
$this->drupalGet('/admin/structure');
$assert_session = $this->assertSession();
$assert_session->pageTextContains($message);
TestSubscriber1::setTestResult(NULL, StatusCheckEvent::class);
$session = $this->getSession();
$session->reload();
$assert_session->pageTextContains($message);
$config = $this->config('automatic_updates.settings');
// If we disable notifications, stored results should not be cleared.
$config->set('status_check_mail', StatusCheckMailer::DISABLED)->save();
$session->reload();
$assert_session->pageTextContains($message);
// If we re-enable them, though, they should be cleared.
$config->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)->save();
$session->reload();
$assert_session->pageTextNotContains($message);
$no_results_message = 'Your site has not recently run an update readiness check.';
$assert_session->pageTextContains($no_results_message);
// If we flag an error again, and keep notifications enabled but change
// their sensitivity level, the stored results should be cleared.
TestSubscriber1::setTestResult([$result], StatusCheckEvent::class);
$session->getPage()->clickLink('Run readiness checks now');
$this->drupalGet('/admin/structure');
$assert_session->pageTextContains($message);
$config->set('status_check_mail', StatusCheckMailer::ALL)->save();
$session->reload();
$assert_session->pageTextNotContains($message);
$assert_session->pageTextContains($no_results_message);
}
/**
* Asserts that the status check requirement displays no errors or warnings.
*
......
......@@ -2,6 +2,7 @@
namespace Drupal\Tests\automatic_updates\Functional;
use Drupal\automatic_updates\StatusCheckMailer;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
......@@ -39,10 +40,14 @@ class UpdatePathTest extends UpdatePathTestBase {
// Ensure the stored value will still be retrievable.
$key_value->setWithExpire($old_key, $value, 3600);
}
$this->assertEmpty($this->config('automatic_updates.settings')->get('status_check_mail'));
$this->runUpdates();
foreach ($map as $new_key) {
$this->assertNotEmpty($key_value->get($new_key));
}
$this->assertSame(StatusCheckMailer::ERRORS_ONLY, $this->config('automatic_updates.settings')->get('status_check_mail'));
// Ensure that the router was rebuilt and routes have the expected changes.
$routes = $this->container->get('router')->getRouteCollection();
......
......@@ -7,7 +7,6 @@ use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
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;
......@@ -20,6 +19,7 @@ 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\automatic_updates\Traits\EmailNotificationsTestTrait;
use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\update\UpdateSettingsForm;
......@@ -34,7 +34,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
*/
class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
use AssertMailTrait;
use EmailNotificationsTestTrait;
use PackageManagerBypassTestTrait;
use UserCreationTrait;
......@@ -54,18 +54,6 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
*/
private $logger;
/**
* The people who should be emailed about successful or failed updates.
*
* The keys are the email addresses, and the values are the langcode they
* should be emailed in.
*
* @var string[]
*
* @see ::setUp()
*/
private $emailRecipients = [];
/**
* {@inheritdoc}
*/
......@@ -79,25 +67,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
$this->installEntitySchema('user');
$this->installSchema('user', ['users_data']);
// Prepare the recipient list to email when an update succeeds or fails.
// First, create a user whose preferred language is different from the
// default language, so we can be sure they're emailed in their preferred
// language; we also ensure that an email which doesn't correspond to a user
// account is emailed in the default language.
$default_language = $this->container->get('language_manager')
->getDefaultLanguage()
->getId();
$this->assertNotSame('fr', $default_language);
$account = $this->createUser([], NULL, FALSE, [
'preferred_langcode' => 'fr',
]);
$this->emailRecipients['emissary@deep.space'] = $default_language;
$this->emailRecipients[$account->getEmail()] = $account->getPreferredLangcode();
$this->config('update.settings')
->set('notification.emails', array_keys($this->emailRecipients))
->save();
$this->setUpEmailRecipients();
}
/**
......@@ -516,46 +486,6 @@ END;
$this->assertMessagesSent('URGENT: Drupal core update failed', $expected_body);
}
/**
* Asserts that all recipients received 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' => $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'];
$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']);
}
}
/**
* Tests that setLogger is called on the cron updater service.
*/
......
<?php
namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
use Drupal\automatic_updates\CronUpdater;
use Drupal\automatic_updates\StatusCheckMailer;
use Drupal\automatic_updates_test\Datetime\TestTime;
use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
use Drupal\Core\Url;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\system\SystemManager;
use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
/**
* Tests status check failure notification emails during cron runs.
*
* @group automatic_updates
*
* @covers \Drupal\automatic_updates\StatusCheckMailer
*/
class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
use EmailNotificationsTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',
'automatic_updates_test',
'package_manager_test_validation',
'user',
];
/**
* The number of times cron has been run.
*
* @var int
*/
private $cronRunCount = 0;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Simulate that we're already fully up to date.
$this->setCoreVersion('9.8.1');
$this->installEntitySchema('user');
$this->installSchema('user', ['users_data']);
$this->installConfig('automatic_updates');
$this->setUpEmailRecipients();
// Allow stored available update data to live for a very, very long time.
// By default, the data expires after one day, but this test runs cron many
// times, with a simulated two hour interval between each run (see
// ::runCron()). Without this long grace period, all the cron runs in this
// test would need to run on the same "day", to prevent certain validators
// from breaking this test due to available update data being irretrievable.
$this->config('update.settings')
->set('check.interval_days', 30)
->save();
}
/**
* Runs cron, simulating a two-hour interval since the previous run.
*
* We need to simulate that at least an hour has passed since the previous
* run, so that our cron hook will run status checks again.
*
* @see automatic_updates_cron()
*/
private function runCron(): void {
$offset = $this->cronRunCount * 2;
$this->cronRunCount++;
TestTime::setFakeTimeByOffset("+$offset hours");
$this->container->get('cron')->run();
}
/**
* Asserts that a certain number of failure notifications has been sent.
*
* @param int $expected_count
* The expected number of failure notifications that should have been sent.
*/
private function assertSentMessagesCount(int $expected_count): void {
$sent_messages = $this->getMails([
'id' => 'automatic_updates_status_check_failed',
]);
$this->assertCount($expected_count, $sent_messages);
}
/**
* Tests that status check failures will trigger e-mails in some situations.
*/
public function testFailureNotifications(): void {
// No messages should have been sent yet.
$this->assertSentMessagesCount(0);
$error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
$this->runCron();
$url = Url::fromRoute('system.status')
->setAbsolute()
->toString();
$expected_body = <<<END
Your site has failed some readiness checks for automatic updates and may not be able to receive automatic updates until further action is taken. Please visit $url for more information.
END;
$this->assertMessagesSent('Automatic updates readiness checks failed', $expected_body);
// Running cron again should not trigger another e-mail (i.e., each
// recipient has only been e-mailed once) since the results are unchanged.
$recipient_count = count($this->emailRecipients);
$this->assertGreaterThan(0, $recipient_count);
$sent_messages_count = $recipient_count;
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If a different error is flagged, they should be e-mailed again.
$error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
// If we flag the same error, but a new warning, they should not be e-mailed
// again because we ignore warnings by default, and they've already been
// e-mailed about this error.
$results = [
$error,
$this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
];
TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If only a warning is flagged, they should not be e-mailed again because
// we ignore warnings by default.
$warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If we stop ignoring warnings, they should be e-mailed again because we
// clear the stored results if the relevant configuration is changed.
$config = $this->config('automatic_updates.settings');
$config->set('status_check_mail', StatusCheckMailer::ALL)->save();
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
// If we flag a different warning, they should be e-mailed again.
$warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
// If we flag multiple warnings, they should be e-mailed again because the
// number of results has changed, even if the severity hasn't.
$warnings = [
$this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
$this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
];
TestSubscriber1::setTestResult($warnings, StatusCheckEvent::class);
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
// If we flag an error and a warning, they should be e-mailed again because
// the severity has changed, even if the number of results hasn't.
$results = [
$this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
$this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
];
TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
// If we change the order of the results, they should not be e-mailed again
// because we are handling the possibility of the results being in a
// different order.
$results = array_reverse($results);
TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If we disable notifications entirely, they should not be e-mailed even
// if a different error is flagged.
$config->set('status_check_mail', StatusCheckMailer::DISABLED)->save();
$error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If we re-enable notifications and go back to ignoring warnings, they
// should not be e-mailed if a new warning is flagged.
$config->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)->save();
$warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If we disable unattended updates entirely and flag a new error, they
// should not be e-mailed.
$config->set('cron', CronUpdater::DISABLED)->save();
$error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
$this->runCron();
$this->assertSentMessagesCount($sent_messages_count);
// If we re-enable unattended updates, they should be emailed again, even if
// the results haven't changed.
$config->set('cron', CronUpdater::ALL)->save();
$this->runCron();
$sent_messages_count += $recipient_count;
$this->assertSentMessagesCount($sent_messages_count);
}
}
......@@ -22,6 +22,7 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
*/
protected static $modules = [
'automatic_updates_test',
'package_manager_test_validation',
'user',
];
......@@ -39,7 +40,8 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
* @covers ::getResults
*/
public function testGetResults(): void {
$this->enableModules(['automatic_updates', 'automatic_updates_test2']);
$this->container->get('module_installer')
->install(['automatic_updates', 'automatic_updates_test2']);
$this->assertCheckerResultsFromManager([], TRUE);
$checker_1_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
$checker_2_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
......@@ -210,11 +212,11 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
* Tests that stored validation results are deleted after an update.
*/
public function testStoredResultsDeletedPostApply(): void {
$this->enableModules(['automatic_updates']);
$this->setCoreVersion('9.8.0');
$this->setReleaseMetadata([
'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml',
]);
$this->container->get('module_installer')->install(['automatic_updates']);
// The status checker should raise a warning, so that the update is not
// blocked or aborted.
......@@ -258,7 +260,7 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
* Tests that certain config changes clear stored results.
*/
public function testStoredResultsClearedOnConfigChanges(): void {
$this->enableModules(['automatic_updates']);
$this->container->get('module_installer')->install(['automatic_updates']);
$results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
......
<?php
namespace Drupal\Tests\automatic_updates\Traits;
use Drupal\Core\Test\AssertMailTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* Contains helper methods for testing e-mail sent by Automatic Updates.
*/
trait EmailNotificationsTestTrait {
use AssertMailTrait;
use UserCreationTrait;
/**
* The people who should be emailed about successful or failed updates.
*
* The keys are the email addresses, and the values are the langcode they
* should be emailed in.
*
* @var string[]
*
* @see ::setUpEmailRecipients()
*/
protected $emailRecipients = [];
/**
* Prepares the recipient list for e-mails related to Automatic Updates.
*/
protected function setUpEmailRecipients(): void {
// First, create a user whose preferred language is different from the
// default language, so we can be sure they're emailed in their preferred
// language; we also ensure that an email which doesn't correspond to a user
// account is emailed in the default language.
$default_language = $this->container->get('language_manager')
->getDefaultLanguage()
->getId();
$this->assertNotSame('fr', $default_language);
$account = $this->createUser([], NULL, FALSE, [
'preferred_langcode' => 'fr',
]);
$this->emailRecipients['emissary@deep.space'] = $default_language;
$this->emailRecipients[$account->getEmail()] = $account->getPreferredLangcode();
$this->config('update.settings')
->set('notification.emails', array_keys($this->emailRecipients))
->save();
}
/**
* Asserts that all recipients received 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
*/
protected function assertMessagesSent(string $subject, string $body): void {
$sent_messages = $this->getMails([
'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'];
$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']);
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment